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
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent 5ade5073f8
commit 060cbd2a91
14 changed files with 464 additions and 177 deletions
@@ -17,9 +17,13 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -195,6 +199,15 @@ class LibraryRepository(
): Flow<PagingData<PlaylistSongItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) } ): Flow<PagingData<PlaylistSongItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
private val starLock = Mutex() 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( suspend fun setStar(
type: StarType, type: StarType,
@@ -3,11 +3,14 @@ package com.subtracks.playback
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C 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.ExperimentalApi
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
@@ -15,21 +18,73 @@ import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.subtracks.MainActivity import com.subtracks.MainActivity
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 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 okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
private var session: MediaSession? = null private var session: MediaSession? = null
private var artworkLoader: CoverArtBitmapLoader? = 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<SessionResult> {
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) @OptIn(UnstableApi::class, ExperimentalApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
val exoPlayer = val exoPlayer =
ExoPlayer ExoPlayer
.Builder(this) .Builder(this)
@@ -46,10 +101,20 @@ class PlaybackService : MediaSessionService() {
.setEnableClippingInMediaPeriod(true) .setEnableClippingInMediaPeriod(true)
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))), .setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
).build() ).build()
exoPlayer.addListener(
object : Player.Listener {
override fun onMediaItemTransition(
mediaItem: MediaItem?,
reason: Int,
) {
observeStar(mediaItem?.mediaId)
}
},
)
val loader = val loader =
CoverArtBitmapLoader( CoverArtBitmapLoader(
applicationContext, applicationContext,
GlobalContext.get().get<SourceRepository>(), sources,
SingletonImageLoader.get(this), SingletonImageLoader.get(this),
) )
artworkLoader = loader artworkLoader = loader
@@ -64,6 +129,7 @@ class PlaybackService : MediaSessionService() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
), ),
).setBitmapLoader(loader) ).setBitmapLoader(loader)
.setCallback(starCallback)
.build() .build()
} }
@@ -74,6 +140,40 @@ class PlaybackService : MediaSessionService() {
pauseAllPlayersAndStopSelf() 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 = private fun streamingClient(): OkHttpClient =
OkHttpClient OkHttpClient
.Builder() .Builder()
@@ -90,6 +190,8 @@ class PlaybackService : MediaSessionService() {
}.build() }.build()
override fun onDestroy() { override fun onDestroy() {
starJob?.cancel()
scope.cancel()
artworkLoader?.shutdown() artworkLoader?.shutdown()
artworkLoader = null artworkLoader = null
session?.run { session?.run {
@@ -102,6 +204,7 @@ class PlaybackService : MediaSessionService() {
private companion object { private companion object {
const val STREAM_TAG = "SubtracksPlayback" const val STREAM_TAG = "SubtracksPlayback"
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
} }
} }
@@ -298,7 +298,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar, setStar = libraryRepository::star,
bottomInset = bottomInset, bottomInset = bottomInset,
) )
} }
@@ -331,7 +331,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar, setStar = libraryRepository::star,
) )
} }
composable( composable(
@@ -358,7 +358,7 @@ private fun MainNavigation() {
}, },
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar, setStar = libraryRepository::star,
) )
} }
composable( composable(
@@ -373,7 +373,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar, setStar = libraryRepository::star,
) )
} }
composable(Routes.ADD_SERVER) { composable(Routes.ADD_SERVER) {
@@ -445,7 +445,7 @@ private fun MainNavigation() {
fadeNowPlaying() fadeNowPlaying()
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar, setStar = libraryRepository::star,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
@@ -310,25 +309,14 @@ fun StarredBadge(
starred: Boolean, starred: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 14.dp, size: Dp = 14.dp,
shadow: Boolean = false,
) { ) {
if (starred) { if (starred) {
Box(modifier.size(size)) { Icon(
if (shadow) { imageVector = Icons.Rounded.Star,
Icon( contentDescription = "Starred",
imageVector = Icons.Rounded.Star, tint = MaterialTheme.colorScheme.primary,
contentDescription = null, modifier = modifier.size(size),
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(),
)
}
} }
} }
@@ -50,6 +50,7 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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
@@ -124,7 +125,7 @@ class ItemActions(
val download: ((SongModel) -> Unit)? = null, val download: ((SongModel) -> Unit)? = null,
val cancelDownload: ((SongModel) -> Unit)? = null, val cancelDownload: ((SongModel) -> Unit)? = null,
val deleteDownload: ((SongModel) -> Unit)? = null, val deleteDownload: ((SongModel) -> Unit)? = null,
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) }, val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
val viewAlbum: ((String) -> Unit)? = null, val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null,
) )
@@ -194,7 +195,11 @@ fun ItemContextMenu(
} }
queueItems() queueItems()
DownloadItem(target.download, target.song, actions, ::dismiss) 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 -> target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view -> actions.viewAlbum?.let { view ->
MenuItem(Icons.Rounded.Album, target.song.album?.takeIf { it.isNotBlank() } ?: "Album") { 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.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } }
queueItems() 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 -> target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, target.album.albumArtist?.takeIf { it.isNotBlank() } ?: "Artist") { MenuItem(Icons.Rounded.Person, target.album.albumArtist?.takeIf { it.isNotBlank() } ?: "Artist") {
@@ -226,7 +235,11 @@ fun ItemContextMenu(
} }
is MenuTarget.Artist -> { 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 -> { is MenuTarget.Playlist -> {
@@ -308,7 +321,8 @@ private fun DownloadItem(
@Composable @Composable
private fun StarItem( private fun StarItem(
current: Long?, current: Long?,
onSet: suspend (Boolean) -> Result<Unit>, onSet: (Boolean) -> Unit,
onDismiss: () -> Unit,
) { ) {
var starred by remember(current) { mutableStateOf(current) } var starred by remember(current) { mutableStateOf(current) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -317,11 +331,12 @@ private fun StarItem(
starLabel(starred), starLabel(starred),
tint = if (starred == null) null else MaterialTheme.colorScheme.primary, tint = if (starred == null) null else MaterialTheme.colorScheme.primary,
) { ) {
val previous = starred val starring = starred == null
val starring = previous == null
starred = if (starring) System.currentTimeMillis() else null starred = if (starring) System.currentTimeMillis() else null
onSet(starring)
scope.launch { 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 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 fun starLabel(starred: Long?): String = if (starred == null) "Star" else "Unstar"
private const val STAR_DISMISS_DELAY_MS = 400L
@@ -49,7 +49,7 @@ fun AlbumDetailRoute(
onBack: () -> Unit, onBack: () -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>, setStar: (StarType, String, Boolean) -> Unit,
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
@@ -171,7 +171,8 @@ fun AlbumDetailScreen(
SongRow( SongRow(
song = song, song = song,
isPlaying = song.id == playingSongId, isPlaying = song.id == playingSongId,
trackNumber = song.track, trackNumber = song.track ?: (index + 1).toLong(),
durationSeconds = song.duration,
download = downloads[song.id], download = downloads[song.id],
modifier = modifier =
rowModifier.combinedClickable( rowModifier.combinedClickable(
@@ -2,21 +2,18 @@ package com.subtracks.ui.library
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag 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.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.StarredBadge
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -88,27 +84,19 @@ fun AlbumsContent(
if (album != null) { if (album != null) {
val art = coverArt(album.coverArt, true) val art = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(art) PrefetchArtworkSeeds(art)
Box { CoverArt(
CoverArt( ref = art,
ref = art, name = album.name,
name = album.name, modifier =
modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .aspectRatio(1f)
.aspectRatio(1f) .clip(RoundedCornerShape(2.dp))
.clip(RoundedCornerShape(2.dp)) .combinedClickable(
.combinedClickable( onClick = { onAlbumClick(album) },
onClick = { onAlbumClick(album) }, onLongClick = { onLongClick(MenuTarget.Album(album, art)) },
onLongClick = { onLongClick(MenuTarget.Album(album, art)) }, ).testTag(ALBUM_COVER_TAG),
).testTag(ALBUM_COVER_TAG), )
)
StarredBadge(
starred = album.starred != null,
size = 16.dp,
shadow = true,
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
)
}
} }
} }
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
@@ -68,7 +68,6 @@ import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.StarredBadge
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
@@ -112,7 +111,7 @@ fun ArtistDetailRoute(
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>, setStar: (StarType, String, Boolean) -> Unit,
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
@@ -284,21 +283,13 @@ fun ArtistDetailScreen(
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail)) }, onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail)) },
).onSizeChanged { albumHeightPx = it.height.toFloat() }, ).onSizeChanged { albumHeightPx = it.height.toFloat() },
) { ) {
Box { CoverArt(
CoverArt( ref = art,
ref = art, name = album.name,
name = album.name, thumbnailRef = thumbnail,
thumbnailRef = thumbnail, showPlaceholder = art == null,
showPlaceholder = art == null, modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
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),
)
}
Text( Text(
text = album.name, text = album.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -181,7 +181,7 @@ fun LibraryRoute(
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>, setStar: (StarType, String, Boolean) -> Unit,
bottomInset: Dp, bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(), viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
@@ -39,7 +39,7 @@ fun PlaylistDetailRoute(
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>, setStar: (StarType, String, Boolean) -> Unit,
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
@@ -121,6 +121,7 @@ fun PlaylistDetailScreen(
coverArtId = item.coverArt, coverArtId = item.coverArt,
coverArt = coverArt, coverArt = coverArt,
isPlaying = item.song.id == playingSongId, isPlaying = item.song.id == playingSongId,
durationSeconds = item.song.duration,
download = downloads[item.song.id], download = downloads[item.song.id],
modifier = modifier =
rowModifier.combinedClickable( rowModifier.combinedClickable(
@@ -1,11 +1,15 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.rounded.DownloadDone import androidx.compose.material.icons.rounded.DownloadDone
import androidx.compose.material.icons.rounded.PlayArrow 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.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.ui.components.CoverArt 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 @Composable
fun SongRow( fun SongRow(
@@ -39,76 +49,154 @@ fun SongRow(
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null, coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
isPlaying: Boolean = false, isPlaying: Boolean = false,
trackNumber: Long? = null, trackNumber: Long? = null,
durationSeconds: Long? = null,
download: SongDownload? = null, download: SongDownload? = null,
trailingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val leadingTrack = trackNumber?.takeIf { coverArt == null }
ListItem( ListItem(
modifier = modifier, modifier = modifier,
leadingContent = leadingContent =
when { if (coverArt != null) {
coverArt != null -> { {
{ CoverArtCell(
CoverArt( ref = coverArt(coverArtId, true),
ref = coverArt(coverArtId, true), name = song.album ?: song.title,
name = song.album ?: song.title, isPlaying = isPlaying,
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp)), )
)
}
}
leadingTrack != null -> {
{ TrackNumber(leadingTrack, isPlaying) }
}
else -> {
null
} }
} else {
{ TrackCell(trackNumber, isPlaying) }
}, },
trailingContent = trailingContent, trailingContent = {
headlineContent = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (isPlaying && leadingTrack == null) { if (download.showsIndicator()) DownloadBadge(download)
Icon( if (durationSeconds != null) TrackTime(durationSeconds)
imageVector = Icons.Rounded.PlayArrow, trailingContent?.invoke()
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)
} }
}, },
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 = { supportingContent = {
Row( Text(
verticalAlignment = Alignment.CenterVertically, text = song.artist.orEmpty().ifEmpty { "\u00A0" },
horizontalArrangement = Arrangement.spacedBy(4.dp), color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) { maxLines = 1,
DownloadBadge(download) 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), 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 @Composable
private fun DownloadBadge(download: SongDownload?) { private fun DownloadBadge(download: SongDownload?) {
when (download?.status) { 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,
)
}
}
}
@@ -27,6 +27,8 @@ import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.ShuffleOn import androidx.compose.material.icons.rounded.ShuffleOn
import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -94,7 +96,7 @@ fun NowPlayingRoute(
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>, setStar: (StarType, String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controller: PlaybackController = koinInject(), controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(), libraryRepository: LibraryRepository = koinInject(),
@@ -160,6 +162,8 @@ fun NowPlayingRoute(
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } }, onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } }, onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
onArtistClick = song?.artistId?.let { id -> { onViewArtist(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, onSeek = controller::seekTo,
modifier = modifier, modifier = modifier,
) )
@@ -205,6 +209,8 @@ fun NowPlayingScreen(
onMore: () -> Unit = {}, onMore: () -> Unit = {},
onAlbumClick: (() -> Unit)? = null, onAlbumClick: (() -> Unit)? = null,
onArtistClick: (() -> Unit)? = null, onArtistClick: (() -> Unit)? = null,
starred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
thumbnailRef: CoverArtRef? = null, thumbnailRef: CoverArtRef? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -303,33 +309,53 @@ fun NowPlayingScreen(
MaterialTheme.typography.bodyMedium.lineHeight MaterialTheme.typography.bodyMedium.lineHeight
.toDp() .toDp()
} }
Column( Row(
horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth()
.padding(top = 12.dp) .padding(top = 12.dp)
.height(titleHeight + 6.dp + subtitleHeight) .height(titleHeight + 6.dp + subtitleHeight),
.clickable(
enabled = onArtistClick != null,
onClickLabel = "Open artist",
) { onArtistClick?.invoke() },
) { ) {
Box(Modifier.height(titleHeight), contentAlignment = Alignment.Center) { Column(
Text( horizontalAlignment = Alignment.Start,
text = state.item?.title.orEmpty(), verticalArrangement = Arrangement.spacedBy(6.dp),
style = titleStyle, modifier =
maxLines = 1, Modifier
modifier = Modifier.basicMarquee(), .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) { IconButton(onClick = { onToggleStar?.invoke() }, enabled = onToggleStar != null) {
Text( Icon(
text = state.item?.artist.orEmpty(), imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
style = subtitleStyle, contentDescription = if (starred) "Unstar" else "Star",
color = MaterialTheme.colorScheme.onSurfaceVariant, tint =
maxLines = 1, if (starred) {
modifier = Modifier.basicMarquee(), MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) )
} }
} }
@@ -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<ComponentActivity>()
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)
}
}
@@ -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))
}
}