mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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.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,27 +309,16 @@ 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)) {
|
|
||||||
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(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Star,
|
imageVector = Icons.Rounded.Star,
|
||||||
contentDescription = "Starred",
|
contentDescription = "Starred",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.matchParentSize(),
|
modifier = modifier.size(size),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.statusBarScrim(): Modifier {
|
fun Modifier.statusBarScrim(): Modifier {
|
||||||
|
|||||||
@@ -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,7 +84,6 @@ 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,
|
||||||
@@ -102,13 +97,6 @@ fun AlbumsContent(
|
|||||||
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,7 +283,6 @@ 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,
|
||||||
@@ -292,13 +290,6 @@ fun ArtistDetailScreen(
|
|||||||
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 -> {
|
|
||||||
{
|
{
|
||||||
CoverArt(
|
CoverArtCell(
|
||||||
ref = coverArt(coverArtId, true),
|
ref = coverArt(coverArtId, true),
|
||||||
name = song.album ?: song.title,
|
name = song.album ?: song.title,
|
||||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp)),
|
isPlaying = isPlaying,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
{ TrackCell(trackNumber, isPlaying) }
|
||||||
leadingTrack != null -> {
|
|
||||||
{ TrackNumber(leadingTrack, isPlaying) }
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
text = song.title,
|
text = song.title,
|
||||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
color = if (isPlaying) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
||||||
fontWeight = if (isPlaying) FontWeight.SemiBold else null,
|
fontWeight = if (isPlaying) FontWeight.SemiBold else null,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
StarredBadge(song.starred != null)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
DownloadBadge(download)
|
|
||||||
Text(
|
Text(
|
||||||
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
|
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
|
||||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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,19 +309,26 @@ fun NowPlayingScreen(
|
|||||||
MaterialTheme.typography.bodyMedium.lineHeight
|
MaterialTheme.typography.bodyMedium.lineHeight
|
||||||
.toDp()
|
.toDp()
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.height(titleHeight + 6.dp + subtitleHeight),
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 12.dp)
|
.weight(1f)
|
||||||
.height(titleHeight + 6.dp + subtitleHeight)
|
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = onArtistClick != null,
|
enabled = onArtistClick != null,
|
||||||
onClickLabel = "Open artist",
|
onClickLabel = "Open artist",
|
||||||
) { onArtistClick?.invoke() },
|
) { onArtistClick?.invoke() },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.height(titleHeight), contentAlignment = Alignment.Center) {
|
Box(Modifier.height(titleHeight), contentAlignment = Alignment.CenterStart) {
|
||||||
Text(
|
Text(
|
||||||
text = state.item?.title.orEmpty(),
|
text = state.item?.title.orEmpty(),
|
||||||
style = titleStyle,
|
style = titleStyle,
|
||||||
@@ -323,7 +336,7 @@ fun NowPlayingScreen(
|
|||||||
modifier = Modifier.basicMarquee(),
|
modifier = Modifier.basicMarquee(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.Center) {
|
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||||
Text(
|
Text(
|
||||||
text = state.item?.artist.orEmpty(),
|
text = state.item?.artist.orEmpty(),
|
||||||
style = subtitleStyle,
|
style = subtitleStyle,
|
||||||
@@ -333,6 +346,19 @@ fun NowPlayingScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
var dragging by remember { mutableStateOf(false) }
|
var dragging by remember { mutableStateOf(false) }
|
||||||
var dragPosition by remember { mutableFloatStateOf(0f) }
|
var dragPosition by remember { mutableFloatStateOf(0f) }
|
||||||
Slider(
|
Slider(
|
||||||
|
|||||||
@@ -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