From 060cbd2a9156a33061cd3c3146fabf5528c9579c Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:27:21 +0000 Subject: [PATCH] rework song rows and now playing, add star to the notification - star/unstar from the context menu fills the icon, then dismisses - drop the star badge from album art and song rows - song rows keep a track-number column (natural order fallback), shrink four-digit numbers, and gain right-hand download and duration columns - the play indicator always sits in the left column, overlaid on cover art with an inset primary outline when art is shown - now playing is left aligned with a star toggle - the media notification gets a star button backed by a session command --- .../subtracks/data/repo/LibraryRepository.kt | 13 ++ .../com/subtracks/playback/PlaybackService.kt | 105 ++++++++- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 10 +- .../com/subtracks/ui/components/Components.kt | 24 +- .../ui/components/ItemContextMenu.kt | 33 ++- .../subtracks/ui/library/AlbumDetailScreen.kt | 5 +- .../com/subtracks/ui/library/AlbumsContent.kt | 38 ++-- .../ui/library/ArtistDetailScreen.kt | 25 +- .../com/subtracks/ui/library/LibraryScreen.kt | 2 +- .../ui/library/PlaylistDetailScreen.kt | 3 +- .../com/subtracks/ui/library/SongsContent.kt | 214 +++++++++++------- .../subtracks/ui/playback/NowPlayingScreen.kt | 72 ++++-- .../ui/components/ItemContextMenuStarTest.kt | 76 +++++++ .../com/subtracks/ui/library/TrackTimeTest.kt | 21 ++ 14 files changed, 464 insertions(+), 177 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuStarTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/library/TrackTimeTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index e96b2748..aaee318a 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -17,9 +17,13 @@ import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.source.StarType import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -195,6 +199,15 @@ class LibraryRepository( ): Flow> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) } private val starLock = Mutex() + private val starScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun star( + type: StarType, + id: String, + starred: Boolean, + ) { + starScope.launch { setStar(type, id, starred) } + } suspend fun setStar( type: StarType, diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 1cd8fa73..5c612555 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -3,11 +3,14 @@ package com.subtracks.playback import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Bundle import android.os.SystemClock import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource @@ -15,21 +18,73 @@ import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult import coil3.SingletonImageLoader +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import com.subtracks.MainActivity +import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.source.StarType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.koin.core.context.GlobalContext class PlaybackService : MediaSessionService() { private var session: MediaSession? = null private var artworkLoader: CoverArtBitmapLoader? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private var sourceRepository: SourceRepository? = null + private var library: LibraryRepository? = null + private var currentSongId: String? = null + private var starred = false + private var starJob: Job? = null + + private val starCallback = + object : MediaSession.Callback { + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): MediaSession.ConnectionResult = + MediaSession.ConnectionResult + .AcceptedResultBuilder(session) + .setAvailableSessionCommands( + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(STAR_COMMAND) + .build(), + ).build() + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + if (customCommand.customAction != STAR_COMMAND.customAction) { + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)) + } + toggleStar() + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + } @OptIn(UnstableApi::class, ExperimentalApi::class) override fun onCreate() { super.onCreate() + val sources = GlobalContext.get().get() + sourceRepository = sources + library = GlobalContext.get().get() val exoPlayer = ExoPlayer .Builder(this) @@ -46,10 +101,20 @@ class PlaybackService : MediaSessionService() { .setEnableClippingInMediaPeriod(true) .setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))), ).build() + exoPlayer.addListener( + object : Player.Listener { + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + observeStar(mediaItem?.mediaId) + } + }, + ) val loader = CoverArtBitmapLoader( applicationContext, - GlobalContext.get().get(), + sources, SingletonImageLoader.get(this), ) artworkLoader = loader @@ -64,6 +129,7 @@ class PlaybackService : MediaSessionService() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ), ).setBitmapLoader(loader) + .setCallback(starCallback) .build() } @@ -74,6 +140,40 @@ class PlaybackService : MediaSessionService() { pauseAllPlayersAndStopSelf() } + private fun observeStar(songId: String?) { + starJob?.cancel() + currentSongId = songId + val library = library ?: return + if (songId == null) { + starred = false + session?.setMediaButtonPreferences(emptyList()) + return + } + starJob = + scope.launch { + val sourceId = sourceRepository?.activeSourceIdOnce() ?: return@launch + library.song(sourceId, songId).collect { song -> + if (currentSongId != songId) return@collect + starred = song?.starred != null + session?.setMediaButtonPreferences(listOf(starButton(starred))) + } + } + } + + private fun toggleStar() { + val songId = currentSongId ?: return + val library = library ?: return + starred = !starred + library.star(StarType.Song, songId, starred) + } + + private fun starButton(isStarred: Boolean): CommandButton = + CommandButton + .Builder(if (isStarred) CommandButton.ICON_STAR_FILLED else CommandButton.ICON_STAR_UNFILLED) + .setSessionCommand(STAR_COMMAND) + .setDisplayName(if (isStarred) "Unstar" else "Star") + .build() + private fun streamingClient(): OkHttpClient = OkHttpClient .Builder() @@ -90,6 +190,8 @@ class PlaybackService : MediaSessionService() { }.build() override fun onDestroy() { + starJob?.cancel() + scope.cancel() artworkLoader?.shutdown() artworkLoader = null session?.run { @@ -102,6 +204,7 @@ class PlaybackService : MediaSessionService() { private companion object { const val STREAM_TAG = "SubtracksPlayback" + val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index dcd7cf6c..3f5d5106 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -298,7 +298,7 @@ private fun MainNavigation() { navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) }, contextMenuHost = contextMenuHost, - setStar = libraryRepository::setStar, + setStar = libraryRepository::star, bottomInset = bottomInset, ) } @@ -331,7 +331,7 @@ private fun MainNavigation() { navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) }, contextMenuHost = contextMenuHost, - setStar = libraryRepository::setStar, + setStar = libraryRepository::star, ) } composable( @@ -358,7 +358,7 @@ private fun MainNavigation() { }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, contextMenuHost = contextMenuHost, - setStar = libraryRepository::setStar, + setStar = libraryRepository::star, ) } composable( @@ -373,7 +373,7 @@ private fun MainNavigation() { navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) }, contextMenuHost = contextMenuHost, - setStar = libraryRepository::setStar, + setStar = libraryRepository::star, ) } composable(Routes.ADD_SERVER) { @@ -445,7 +445,7 @@ private fun MainNavigation() { fadeNowPlaying() }, contextMenuHost = contextMenuHost, - setStar = libraryRepository::setStar, + setStar = libraryRepository::star, modifier = Modifier.fillMaxSize(), ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index 8aa36f98..95d1f9c4 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars @@ -310,25 +309,14 @@ fun StarredBadge( starred: Boolean, modifier: Modifier = Modifier, size: Dp = 14.dp, - shadow: Boolean = false, ) { if (starred) { - Box(modifier.size(size)) { - if (shadow) { - Icon( - imageVector = Icons.Rounded.Star, - contentDescription = null, - tint = Color.Black.copy(alpha = 0.5f), - modifier = Modifier.matchParentSize().offset(x = 0.5.dp, y = 1.dp), - ) - } - Icon( - imageVector = Icons.Rounded.Star, - contentDescription = "Starred", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.matchParentSize(), - ) - } + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = "Starred", + tint = MaterialTheme.colorScheme.primary, + modifier = modifier.size(size), + ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index 0a41ef92..8817f04e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -50,6 +50,7 @@ import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.QueueKind import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.subtracks.data.model.Album as AlbumModel import com.subtracks.data.model.Artist as ArtistModel @@ -124,7 +125,7 @@ class ItemActions( val download: ((SongModel) -> Unit)? = null, val cancelDownload: ((SongModel) -> Unit)? = null, val deleteDownload: ((SongModel) -> Unit)? = null, - val setStar: suspend (StarType, String, Boolean) -> Result = { _, _, _ -> Result.success(Unit) }, + val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> }, val viewAlbum: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null, ) @@ -194,7 +195,11 @@ fun ItemContextMenu( } queueItems() DownloadItem(target.download, target.song, actions, ::dismiss) - StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) } + StarItem( + current = target.song.starred, + onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) }, + onDismiss = { dismiss() }, + ) target.song.albumId?.let { albumId -> actions.viewAlbum?.let { view -> MenuItem(Icons.Rounded.Album, target.song.album?.takeIf { it.isNotBlank() } ?: "Album") { @@ -215,7 +220,11 @@ fun ItemContextMenu( MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } } queueItems() - StarItem(target.album.starred) { starring -> actions.setStar(StarType.Album, target.album.id, starring) } + StarItem( + current = target.album.starred, + onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) }, + onDismiss = { dismiss() }, + ) target.album.artistId?.let { artistId -> actions.viewArtist?.let { view -> MenuItem(Icons.Rounded.Person, target.album.albumArtist?.takeIf { it.isNotBlank() } ?: "Artist") { @@ -226,7 +235,11 @@ fun ItemContextMenu( } is MenuTarget.Artist -> { - StarItem(target.artist.starred) { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) } + StarItem( + current = target.artist.starred, + onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }, + onDismiss = { dismiss() }, + ) } is MenuTarget.Playlist -> { @@ -308,7 +321,8 @@ private fun DownloadItem( @Composable private fun StarItem( current: Long?, - onSet: suspend (Boolean) -> Result, + onSet: (Boolean) -> Unit, + onDismiss: () -> Unit, ) { var starred by remember(current) { mutableStateOf(current) } val scope = rememberCoroutineScope() @@ -317,11 +331,12 @@ private fun StarItem( starLabel(starred), tint = if (starred == null) null else MaterialTheme.colorScheme.primary, ) { - val previous = starred - val starring = previous == null + val starring = starred == null starred = if (starring) System.currentTimeMillis() else null + onSet(starring) scope.launch { - if (onSet(starring).isFailure) starred = previous + delay(STAR_DISMISS_DELAY_MS) + onDismiss() } } } @@ -344,3 +359,5 @@ private fun MenuItem( 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" + +private const val STAR_DISMISS_DELAY_MS = 400L diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 22050eee..ac1970ab 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -49,7 +49,7 @@ fun AlbumDetailRoute( onBack: () -> Unit, onViewArtist: (String) -> Unit, contextMenuHost: ContextMenuHost? = null, - setStar: suspend (StarType, String, Boolean) -> Result, + setStar: (StarType, String, Boolean) -> Unit, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, playbackController: PlaybackController = koinInject(), ) { @@ -171,7 +171,8 @@ fun AlbumDetailScreen( SongRow( song = song, isPlaying = song.id == playingSongId, - trackNumber = song.track, + trackNumber = song.track ?: (index + 1).toLong(), + durationSeconds = song.duration, download = downloads[song.id], modifier = rowModifier.combinedClickable( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 15d8050e..2a4b2066 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -2,21 +2,18 @@ package com.subtracks.ui.library import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag @@ -33,7 +30,6 @@ 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.StarredBadge import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.PrefetchArtworkSeeds @@ -88,27 +84,19 @@ fun AlbumsContent( if (album != null) { val art = coverArt(album.coverArt, true) PrefetchArtworkSeeds(art) - Box { - CoverArt( - ref = art, - name = album.name, - modifier = - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(2.dp)) - .combinedClickable( - onClick = { onAlbumClick(album) }, - onLongClick = { onLongClick(MenuTarget.Album(album, art)) }, - ).testTag(ALBUM_COVER_TAG), - ) - StarredBadge( - starred = album.starred != null, - size = 16.dp, - shadow = true, - modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), - ) - } + CoverArt( + ref = art, + name = album.name, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(2.dp)) + .combinedClickable( + onClick = { onAlbumClick(album) }, + onLongClick = { onLongClick(MenuTarget.Album(album, art)) }, + ).testTag(ALBUM_COVER_TAG), + ) } } item(span = { GridItemSpan(maxLineSpan) }) { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index fe97e088..8524a09c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -68,7 +68,6 @@ import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget -import com.subtracks.ui.components.StarredBadge import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkTheme @@ -112,7 +111,7 @@ fun ArtistDetailRoute( onAlbumClick: (Album) -> Unit, onViewAlbum: (String) -> Unit, contextMenuHost: ContextMenuHost? = null, - setStar: suspend (StarType, String, Boolean) -> Result, + setStar: (StarType, String, Boolean) -> Unit, viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, playbackController: PlaybackController = koinInject(), ) { @@ -284,21 +283,13 @@ fun ArtistDetailScreen( onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail)) }, ).onSizeChanged { albumHeightPx = it.height.toFloat() }, ) { - Box { - CoverArt( - ref = art, - name = album.name, - thumbnailRef = thumbnail, - showPlaceholder = art == null, - modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), - ) - StarredBadge( - starred = album.starred != null, - size = 16.dp, - shadow = true, - modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), - ) - } + CoverArt( + ref = art, + name = album.name, + thumbnailRef = thumbnail, + showPlaceholder = art == null, + modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), + ) Text( text = album.name, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 6c043f21..d0d1e25d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -181,7 +181,7 @@ fun LibraryRoute( onViewAlbum: (String) -> Unit, onViewArtist: (String) -> Unit, contextMenuHost: ContextMenuHost? = null, - setStar: suspend (StarType, String, Boolean) -> Result, + setStar: (StarType, String, Boolean) -> Unit, bottomInset: Dp, viewModel: LibraryViewModel = koinViewModel(), playbackController: PlaybackController = koinInject(), diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index 780e56e2..08f3d24f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -39,7 +39,7 @@ fun PlaylistDetailRoute( onViewAlbum: (String) -> Unit, onViewArtist: (String) -> Unit, contextMenuHost: ContextMenuHost? = null, - setStar: suspend (StarType, String, Boolean) -> Result, + setStar: (StarType, String, Boolean) -> Unit, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, playbackController: PlaybackController = koinInject(), ) { @@ -121,6 +121,7 @@ fun PlaylistDetailScreen( coverArtId = item.coverArt, coverArt = coverArt, isPlaying = item.song.id == playingSongId, + durationSeconds = item.song.duration, download = downloads[item.song.id], modifier = rowModifier.combinedClickable( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index da42156e..21ecb6ef 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -1,11 +1,15 @@ package com.subtracks.ui.library +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DownloadDone import androidx.compose.material.icons.rounded.PlayArrow @@ -23,14 +27,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.ui.components.CoverArt -import com.subtracks.ui.components.StarredBadge + +private val TRACK_COLUMN_WIDTH = 24.dp +private val TIME_COLUMN_WIDTH = 36.dp +private val PLAY_ICON_SIZE = 22.dp +private val MIN_SMALL_FONT = 8.sp @Composable fun SongRow( @@ -39,76 +49,154 @@ fun SongRow( coverArt: ((String?, Boolean) -> CoverArtRef?)? = null, isPlaying: Boolean = false, trackNumber: Long? = null, + durationSeconds: Long? = null, download: SongDownload? = null, trailingContent: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { - val leadingTrack = trackNumber?.takeIf { coverArt == null } ListItem( modifier = modifier, leadingContent = - when { - coverArt != null -> { - { - CoverArt( - ref = coverArt(coverArtId, true), - name = song.album ?: song.title, - modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp)), - ) - } - } - - leadingTrack != null -> { - { TrackNumber(leadingTrack, isPlaying) } - } - - else -> { - null + if (coverArt != null) { + { + CoverArtCell( + ref = coverArt(coverArtId, true), + name = song.album ?: song.title, + isPlaying = isPlaying, + ) } + } else { + { TrackCell(trackNumber, isPlaying) } }, - trailingContent = trailingContent, - headlineContent = { + trailingContent = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (isPlaying && leadingTrack == null) { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = "Playing", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - Text( - text = song.title, - color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified, - fontWeight = if (isPlaying) FontWeight.SemiBold else null, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - StarredBadge(song.starred != null) + if (download.showsIndicator()) DownloadBadge(download) + if (durationSeconds != null) TrackTime(durationSeconds) + trailingContent?.invoke() } }, + headlineContent = { + Text( + text = song.title, + color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified, + fontWeight = if (isPlaying) FontWeight.SemiBold else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + }, supportingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - DownloadBadge(download) - Text( - text = song.artist.orEmpty().ifEmpty { "\u00A0" }, - color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + Text( + text = song.artist.orEmpty().ifEmpty { "\u00A0" }, + color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } +private fun SongDownload?.showsIndicator(): Boolean = + when (this?.status) { + DownloadStatus.Completed, DownloadStatus.Queued, DownloadStatus.Running -> true + else -> false + } + +@Composable +private fun CoverArtCell( + ref: CoverArtRef?, + name: String, + isPlaying: Boolean, +) { + val shape = RoundedCornerShape(6.dp) + Box(Modifier.size(48.dp)) { + CoverArt( + ref = ref, + name = name, + modifier = Modifier.size(48.dp).clip(shape), + ) + if (isPlaying) { + Box( + Modifier + .matchParentSize() + .border(1.5.dp, MaterialTheme.colorScheme.primary, shape), + ) + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Playing", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.Center).size(PLAY_ICON_SIZE), + ) + } + } +} + +@Composable +private fun TrackCell( + track: Long?, + isPlaying: Boolean, +) { + Box( + modifier = Modifier.width(TRACK_COLUMN_WIDTH), + contentAlignment = Alignment.Center, + ) { + if (isPlaying) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Playing", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(PLAY_ICON_SIZE), + ) + } else if (track != null) { + SmallNumber(track.toString()) + } + } +} + +@Composable +private fun TrackTime(seconds: Long) { + SmallNumber( + text = formatTrackTime(seconds), + modifier = Modifier.width(TIME_COLUMN_WIDTH), + ) +} + +@Composable +private fun SmallNumber( + text: String, + modifier: Modifier = Modifier, +) { + val style = MaterialTheme.typography.bodySmall + BasicText( + text = text, + modifier = modifier.fillMaxWidth(), + style = style.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant), + maxLines = 1, + autoSize = + TextAutoSize.StepBased( + minFontSize = MIN_SMALL_FONT, + maxFontSize = style.fontSize, + stepSize = 1.sp, + ), + ) +} + +internal fun formatTrackTime(seconds: Long): String { + val total = seconds.coerceAtLeast(0) + val hours = total / 3600 + val minutes = (total % 3600) / 60 + val secs = total % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, secs) + } else { + "%02d:%02d".format(minutes, secs) + } +} + @Composable private fun DownloadBadge(download: SongDownload?) { when (download?.status) { @@ -142,29 +230,3 @@ private fun DownloadBadge(download: SongDownload?) { } } } - -@Composable -private fun TrackNumber( - track: Long, - isPlaying: Boolean, -) { - Box( - modifier = Modifier.width(18.dp), - contentAlignment = Alignment.Center, - ) { - if (isPlaying) { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = "Playing", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } else { - Text( - text = track.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 26c0510a..70c69bde 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.ShuffleOn import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.SkipPrevious +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -94,7 +96,7 @@ fun NowPlayingRoute( onViewAlbum: (String) -> Unit, onViewArtist: (String) -> Unit, contextMenuHost: ContextMenuHost? = null, - setStar: suspend (StarType, String, Boolean) -> Result, + setStar: (StarType, String, Boolean) -> Unit, modifier: Modifier = Modifier, controller: PlaybackController = koinInject(), libraryRepository: LibraryRepository = koinInject(), @@ -160,6 +162,8 @@ fun NowPlayingRoute( onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } }, onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } }, onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } }, + starred = song?.starred != null, + onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } }, onSeek = controller::seekTo, modifier = modifier, ) @@ -205,6 +209,8 @@ fun NowPlayingScreen( onMore: () -> Unit = {}, onAlbumClick: (() -> Unit)? = null, onArtistClick: (() -> Unit)? = null, + starred: Boolean = false, + onToggleStar: (() -> Unit)? = null, onSeek: (Long) -> Unit, thumbnailRef: CoverArtRef? = null, modifier: Modifier = Modifier, @@ -303,33 +309,53 @@ fun NowPlayingScreen( MaterialTheme.typography.bodyMedium.lineHeight .toDp() } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .fillMaxWidth() .padding(top = 12.dp) - .height(titleHeight + 6.dp + subtitleHeight) - .clickable( - enabled = onArtistClick != null, - onClickLabel = "Open artist", - ) { onArtistClick?.invoke() }, + .height(titleHeight + 6.dp + subtitleHeight), ) { - Box(Modifier.height(titleHeight), contentAlignment = Alignment.Center) { - Text( - text = state.item?.title.orEmpty(), - style = titleStyle, - maxLines = 1, - modifier = Modifier.basicMarquee(), - ) + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .weight(1f) + .clickable( + enabled = onArtistClick != null, + onClickLabel = "Open artist", + ) { onArtistClick?.invoke() }, + ) { + Box(Modifier.height(titleHeight), contentAlignment = Alignment.CenterStart) { + Text( + text = state.item?.title.orEmpty(), + style = titleStyle, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + } + Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) { + Text( + text = state.item?.artist.orEmpty(), + style = subtitleStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + } } - Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.Center) { - Text( - text = state.item?.artist.orEmpty(), - style = subtitleStyle, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.basicMarquee(), + IconButton(onClick = { onToggleStar?.invoke() }, enabled = onToggleStar != null) { + Icon( + imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder, + contentDescription = if (starred) "Unstar" else "Star", + tint = + if (starred) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } } diff --git a/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuStarTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuStarTest.kt new file mode 100644 index 00000000..b10993ce --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuStarTest.kt @@ -0,0 +1,76 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.Song +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ItemContextMenuStarTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val song = + Song( + sourceId = 1, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Song", + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + genre = null, + ) + + @Test + fun starringFillsTheIconImmediatelyThenDismisses() { + var dismissed = false + var starred: Boolean? = null + composeRule.setContent { + SubtracksTheme { + var show by remember { mutableStateOf(true) } + if (show) { + ItemContextMenu( + target = MenuTarget.Song(song), + actions = ItemActions(setStar = { _, _, value -> starred = value }), + onDismiss = { + dismissed = true + show = false + }, + ) + } + } + } + + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithText("Star").performClick() + composeRule.mainClock.advanceTimeBy(100) + + composeRule.onNodeWithText("Unstar").assertIsDisplayed() + assertEquals(true, starred) + assertFalse("the menu should stay open so the new icon is visible", dismissed) + + composeRule.mainClock.advanceTimeBy(1_000) + assertTrue("starring should dismiss the menu", dismissed) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/library/TrackTimeTest.kt b/app/src/test/kotlin/com/subtracks/ui/library/TrackTimeTest.kt new file mode 100644 index 00000000..9aa5b914 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/library/TrackTimeTest.kt @@ -0,0 +1,21 @@ +package com.subtracks.ui.library + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TrackTimeTest { + @Test + fun minutesAndSecondsAreZeroPadded() { + assertEquals("04:11", formatTrackTime(251)) + } + + @Test + fun anHourOrLongerUsesTheHourWithoutPadding() { + assertEquals("1:02:03", formatTrackTime(3_723)) + } + + @Test + fun negativeDurationsClampToZero() { + assertEquals("00:00", formatTrackTime(-5)) + } +}