mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user