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.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<PagingData<PlaylistSongItem>> = 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,
@@ -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<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)
override fun onCreate() {
super.onCreate()
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
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<SourceRepository>(),
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)
}
}
@@ -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(),
)
}
@@ -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),
)
}
}
@@ -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<Unit> = { _, _, _ -> 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<Unit>,
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
@@ -49,7 +49,7 @@ fun AlbumDetailRoute(
onBack: () -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
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(
@@ -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) }) {
@@ -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<Unit>,
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,
@@ -181,7 +181,7 @@ fun LibraryRoute(
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
setStar: (StarType, String, Boolean) -> Unit,
bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(),
@@ -39,7 +39,7 @@ fun PlaylistDetailRoute(
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
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(
@@ -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,
)
}
}
}
@@ -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<Unit>,
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
},
)
}
}
@@ -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))
}
}