Initial commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.Error
|
||||
import it.vfsfitvnm.vimusic.ui.components.MusicBars
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LightColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
|
||||
import it.vfsfitvnm.vimusic.utils.YoutubePlayer
|
||||
import it.vfsfitvnm.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun CurrentPlaylistView(
|
||||
layoutState: BottomSheetState,
|
||||
onGlobalRouteEmitted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val density = LocalDensity.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
val thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val isPaused by derivedStateOf {
|
||||
player?.playbackState == Player.STATE_ENDED || player?.playWhenReady == false
|
||||
}
|
||||
|
||||
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val lazyListState =
|
||||
rememberLazyListState(initialFirstVisibleItemIndex = player?.mediaItemIndex ?: 0)
|
||||
|
||||
val reorderingState = rememberReorderingState(player?.mediaItems ?: emptyList())
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = modifier
|
||||
.nestedScroll(remember {
|
||||
layoutState.nestedScrollConnection(player?.mediaItemIndex == 0)
|
||||
})
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = player?.mediaItems ?: emptyList()
|
||||
) { index, mediaItem ->
|
||||
val isPlayingThisMediaItem by derivedStateOf {
|
||||
player?.mediaItemIndex == index
|
||||
}
|
||||
|
||||
SongItem(
|
||||
mediaItem = mediaItem,
|
||||
thumbnailSize = thumbnailSize,
|
||||
onClick = {
|
||||
if (isPlayingThisMediaItem) {
|
||||
if (isPaused) {
|
||||
player?.mediaController?.play()
|
||||
} else {
|
||||
player?.mediaController?.pause()
|
||||
}
|
||||
} else {
|
||||
player?.mediaController?.playWhenReady = true
|
||||
player?.mediaController?.seekToDefaultPosition(index)
|
||||
}
|
||||
},
|
||||
menuContent = {
|
||||
QueuedMediaItemMenu(
|
||||
mediaItem = mediaItem,
|
||||
indexInQueue = index,
|
||||
onGlobalRouteEmitted = onGlobalRouteEmitted
|
||||
)
|
||||
},
|
||||
onThumbnailContent = {
|
||||
AnimatedVisibility(
|
||||
visible = isPlayingThisMediaItem,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(alpha = 0.25f))
|
||||
.size(54.dp)
|
||||
) {
|
||||
if (isPaused) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(LightColorPalette.background),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
MusicBars(
|
||||
color = LightColorPalette.background,
|
||||
// shape = RectangleShape,
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundColor = colorPalette.elevatedBackground,
|
||||
modifier = Modifier
|
||||
.verticalDragAfterLongPressToReorder(
|
||||
reorderingState = reorderingState,
|
||||
index = index,
|
||||
onDragStart = {
|
||||
hapticFeedback.performHapticFeedback(
|
||||
HapticFeedbackType.LongPress
|
||||
)
|
||||
},
|
||||
onDragEnd = { reachedIndex ->
|
||||
player?.mediaController?.moveMediaItem(index, reachedIndex)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (YoutubePlayer.Radio.isActive && player != null) {
|
||||
when (val nextContinuation = YoutubePlayer.Radio.nextContinuation) {
|
||||
is Outcome.Loading, is Outcome.Success<*> -> {
|
||||
if (nextContinuation is Outcome.Success<*>) {
|
||||
item {
|
||||
SideEffect {
|
||||
coroutineScope.launch {
|
||||
YoutubePlayer.Radio.process(
|
||||
player.mediaController,
|
||||
force = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(count = 3, key = { it }) { index ->
|
||||
SmallSongItemShimmer(
|
||||
shimmer = shimmer,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
is Outcome.Error -> item {
|
||||
Error(
|
||||
error = nextContinuation
|
||||
)
|
||||
}
|
||||
is Outcome.Recovered<*> -> item {
|
||||
Error(
|
||||
error = nextContinuation.error,
|
||||
onRetry = {
|
||||
coroutineScope.launch {
|
||||
YoutubePlayer.Radio.process(player.mediaController, force = true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.with
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.Route
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.route.empty
|
||||
import it.vfsfitvnm.route.rememberRoute
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheet
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.Message
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.screens.rememberLyricsRoute
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import it.vfsfitvnm.youtubemusic.isEvaluable
|
||||
import it.vfsfitvnm.youtubemusic.toNotNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun PlayerBottomSheet(
|
||||
layoutState: BottomSheetState,
|
||||
onGlobalRouteEmitted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val player = LocalYoutubePlayer.current ?: return
|
||||
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val lyricsRoute = rememberLyricsRoute()
|
||||
|
||||
var route by rememberRoute()
|
||||
|
||||
var nextOutcome by remember(player.mediaItem!!.mediaId) {
|
||||
mutableStateOf<Outcome<YouTube.NextResult>>(Outcome.Initial)
|
||||
}
|
||||
|
||||
var lyricsOutcome by remember(player.mediaItem!!.mediaId) {
|
||||
mutableStateOf<Outcome<String?>>(Outcome.Initial)
|
||||
}
|
||||
|
||||
BottomSheet(
|
||||
state = layoutState,
|
||||
peekHeight = 128.dp,
|
||||
elevation = 16.dp,
|
||||
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
|
||||
handleOutsideInteractionsWhenExpanded = true,
|
||||
modifier = modifier,
|
||||
collapsedContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layoutState.lowerBound)
|
||||
.background(colorPalette.elevatedBackground)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.background(color = colorPalette.textDisabled, shape = RoundedCornerShape(16.dp))
|
||||
.width(36.dp)
|
||||
.height(4.dp)
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
) {
|
||||
@Composable
|
||||
fun Element(
|
||||
text: String,
|
||||
targetRoute: Route?
|
||||
) {
|
||||
val color by animateColorAsState(
|
||||
if (targetRoute == route) {
|
||||
colorPalette.text
|
||||
} else {
|
||||
colorPalette.textDisabled
|
||||
}
|
||||
)
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
if (targetRoute == route) {
|
||||
1f
|
||||
} else {
|
||||
0.9f
|
||||
}
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
style = typography.xs.medium.color(color).center,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
route = targetRoute
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
layoutState.expand()
|
||||
}
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.scale(scale)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Element(
|
||||
text = "UP NEXT",
|
||||
targetRoute = null
|
||||
)
|
||||
|
||||
Element(
|
||||
text = "LYRICS",
|
||||
targetRoute = lyricsRoute
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
RouteHandler(
|
||||
route = route,
|
||||
onRouteChanged = {
|
||||
route = it
|
||||
},
|
||||
handleBackPress = false,
|
||||
transitionSpec = {
|
||||
when (targetState.route) {
|
||||
lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left)
|
||||
else -> when (initialState.route) {
|
||||
lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right)
|
||||
else -> empty
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.background(colorPalette.elevatedBackground)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
lyricsRoute {
|
||||
OutcomeItem(
|
||||
outcome = lyricsOutcome,
|
||||
onInitialize = {
|
||||
lyricsOutcome = Outcome.Loading
|
||||
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
if (nextOutcome.isEvaluable) {
|
||||
nextOutcome = Outcome.Loading
|
||||
nextOutcome = withContext(Dispatchers.IO) {
|
||||
YouTube.next(
|
||||
player.mediaItem!!.mediaId,
|
||||
player.mediaItem!!.mediaMetadata.extras?.getString("playlistId"),
|
||||
player.mediaItemIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsOutcome = nextOutcome.flatMap {
|
||||
it.lyrics?.text().toNotNull()
|
||||
}
|
||||
}
|
||||
},
|
||||
onLoading = {
|
||||
LyricsShimmer(
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
)
|
||||
}
|
||||
) { lyrics ->
|
||||
if (lyrics != null) {
|
||||
BasicText(
|
||||
text = lyrics,
|
||||
style = typography.xs.center,
|
||||
modifier = Modifier
|
||||
.padding(top = 64.dp)
|
||||
.nestedScroll(remember { layoutState.nestedScrollConnection() })
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
.padding(horizontal = 48.dp)
|
||||
)
|
||||
} else {
|
||||
Message(
|
||||
text = "Lyrics not available",
|
||||
icon = R.drawable.text,
|
||||
modifier = Modifier
|
||||
.padding(top = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
host {
|
||||
CurrentPlaylistView(
|
||||
layoutState = layoutState,
|
||||
onGlobalRouteEmitted = onGlobalRouteEmitted,
|
||||
modifier = Modifier
|
||||
.padding(top = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsShimmer(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
repeat(16) { index ->
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.05f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
466
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt
Normal file
466
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt
Normal file
@@ -0,0 +1,466 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.TimeBar
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.*
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun PlayerView(
|
||||
layoutState: BottomSheetState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val menuState = LocalMenuState.current
|
||||
val preferences = LocalPreferences.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
player?.mediaItem ?: return
|
||||
|
||||
val smallThumbnailSize = remember {
|
||||
density.run { 64.dp.roundToPx() }
|
||||
}
|
||||
|
||||
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
||||
val size = minOf(configuration.screenHeightDp, configuration.screenWidthDp).dp
|
||||
size to density.run { size.minus(64.dp).roundToPx() }
|
||||
}
|
||||
|
||||
val song by remember(player.mediaItem?.mediaId) {
|
||||
player.mediaItem?.mediaId?.let(Database::songFlow)?.distinctUntilChanged() ?: flowOf(null)
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
|
||||
|
||||
BottomSheet(
|
||||
state = layoutState,
|
||||
modifier = modifier,
|
||||
collapsedContent = {
|
||||
if (!layoutState.isExpanded) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(layoutState.lowerBound)
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f)
|
||||
}
|
||||
.drawWithCache {
|
||||
val offset = 64.dp.toPx()
|
||||
val x = ((size.width - offset) * player.progress) + offset
|
||||
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawLine(
|
||||
color = colorPalette.text,
|
||||
start = Offset(
|
||||
x = offset,
|
||||
y = 1.dp.toPx()
|
||||
),
|
||||
end = Offset(
|
||||
x = x,
|
||||
y = 1.dp.toPx()
|
||||
),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(colorPalette.elevatedBackground)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "${player.mediaMetadata.artworkUri}-w$smallThumbnailSize-h$smallThumbnailSize",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
text = player.mediaMetadata.title?.toString() ?: "",
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText(
|
||||
text = player.mediaMetadata.artist?.toString() ?: "",
|
||||
style = typography.xs,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
if (player.playbackState == Player.STATE_IDLE) {
|
||||
player.mediaController.prepare()
|
||||
}
|
||||
player.mediaController.play()
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
else -> Image(
|
||||
painter = painterResource(R.drawable.pause),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.pause()
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.padding(bottom = 72.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
var scrubbingPosition by remember {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
|
||||
TopAppBar {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
QueuedMediaItemMenu(
|
||||
mediaItem = player.mediaItem ?: MediaItem.EMPTY,
|
||||
indexInQueue = player.mediaItemIndex,
|
||||
onDismiss = menuState::hide,
|
||||
onGlobalRouteEmitted = layoutState.collapse
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (player.error == null) {
|
||||
AnimatedContent(
|
||||
targetState = player.mediaItemIndex,
|
||||
transitionSpec = {
|
||||
val slideDirection =
|
||||
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||
|
||||
(slideIntoContainer(slideDirection) + fadeIn() with
|
||||
slideOutOfContainer(slideDirection) + fadeOut()).using(
|
||||
SizeTransform(clip = false)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
val artworkUri = remember(it) {
|
||||
player.mediaController.getMediaItemAt(it).mediaMetadata.artworkUri
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = "$artworkUri-w$thumbnailSizePx-h$thumbnailSizePx",
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 32.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 32.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
.size(thumbnailSizeDp)
|
||||
) {
|
||||
// BasicText(
|
||||
// text = playerState.error?.message ?: "",
|
||||
// style = typography.xs.medium
|
||||
// )
|
||||
Error(
|
||||
error = Outcome.Error.Unhandled(player.error!!),
|
||||
onRetry = {
|
||||
player.mediaController.playWhenReady = true
|
||||
player.mediaController.prepare()
|
||||
player.error = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = player.mediaMetadata.title?.toString() ?: "",
|
||||
style = typography.l.bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
|
||||
BasicText(
|
||||
text = player.mediaMetadata.extras?.getStringArrayList("artistNames")
|
||||
?.joinToString("") ?: "",
|
||||
style = typography.s.semiBold.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
DefaultTimeBar(context).also {
|
||||
it.setPlayedColor(colorPalette.text.toArgb())
|
||||
it.setUnplayedColor(colorPalette.textDisabled.toArgb())
|
||||
it.setScrubberColor(colorPalette.text.toArgb())
|
||||
it.addListener(object : TimeBar.OnScrubListener {
|
||||
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
|
||||
|
||||
override fun onScrubMove(timeBar: TimeBar, position: Long) {
|
||||
scrubbingPosition = position
|
||||
}
|
||||
|
||||
override fun onScrubStop(
|
||||
timeBar: TimeBar,
|
||||
position: Long,
|
||||
canceled: Boolean
|
||||
) {
|
||||
if (!canceled) {
|
||||
scrubbingPosition = position
|
||||
player.mediaController.seekTo(position)
|
||||
player.currentPosition = player.mediaController.currentPosition
|
||||
}
|
||||
scrubbingPosition = null
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setDuration(player.duration)
|
||||
it.setPosition(player.currentPosition)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
val text by remember {
|
||||
derivedStateOf {
|
||||
DateUtils.formatElapsedTime((scrubbingPosition ?: player.currentPosition) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (player.duration != C.TIME_UNSET) {
|
||||
BasicText(
|
||||
text = DateUtils.formatElapsedTime(player.duration / 1000),
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 32.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.heart),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.update(
|
||||
(song ?: Database.insert(player.mediaItem!!)).toggleLike()
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(28.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play_skip_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.seekToPrevious()
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
|
||||
when {
|
||||
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
|
||||
painter = painterResource(R.drawable.play_circle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
if (player.playbackState == Player.STATE_IDLE) {
|
||||
player.mediaController.prepare()
|
||||
}
|
||||
|
||||
player.mediaController.play()
|
||||
}
|
||||
.size(64.dp)
|
||||
)
|
||||
else -> Image(
|
||||
painter = painterResource(R.drawable.pause_circle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.pause()
|
||||
}
|
||||
.size(64.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play_skip_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.seekToNext()
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
|
||||
|
||||
Image(
|
||||
painter = painterResource(
|
||||
if (player.repeatMode == Player.REPEAT_MODE_ONE) {
|
||||
R.drawable.repeat_one
|
||||
} else {
|
||||
R.drawable.repeat
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
if (player.repeatMode == Player.REPEAT_MODE_OFF) {
|
||||
colorPalette.textDisabled
|
||||
} else {
|
||||
colorPalette.text
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.repeatMode =
|
||||
(player.mediaController.repeatMode + 2) % 3
|
||||
preferences.repeatMode = player.mediaController.repeatMode
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PlayerBottomSheet(
|
||||
layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound - 128.dp),
|
||||
onGlobalRouteEmitted = layoutState.collapse,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 128.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistPreview
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun PlaylistPreviewItem(
|
||||
playlistPreview: PlaylistPreview,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbnailSize: Dp = 54.dp,
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val thumbnailSizePx = density.run {
|
||||
thumbnailSize.toPx().toInt()
|
||||
}
|
||||
|
||||
val thumbnails by remember(playlistPreview.playlist.id) {
|
||||
Database.playlistThumbnailUrls(playlistPreview.playlist.id).distinctUntilChanged()
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(colorPalette.lightBackground)
|
||||
.size(thumbnailSize * 2)
|
||||
) {
|
||||
if (thumbnails.toSet().size == 1) {
|
||||
AsyncImage(
|
||||
model = "${thumbnails.first()}-w${thumbnailSizePx * 2}-h${thumbnailSizePx * 2}",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(thumbnailSize * 2)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Alignment.TopStart,
|
||||
Alignment.TopEnd,
|
||||
Alignment.BottomStart,
|
||||
Alignment.BottomEnd
|
||||
).forEachIndexed { index, alignment ->
|
||||
AsyncImage(
|
||||
model = "${thumbnails.getOrNull(index)}-w$thumbnailSizePx-h$thumbnailSizePx",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.size(thumbnailSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = playlistPreview.playlist.name,
|
||||
style = typography.xxs.semiBold.color(Color.White),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomStart)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.75f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
199
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt
Normal file
199
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.SongWithInfo
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun SongItem(
|
||||
mediaItem: MediaItem,
|
||||
thumbnailSize: Int,
|
||||
onClick: () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = ImageRequest.Builder(LocalContext.current)
|
||||
.diskCacheKey(mediaItem.mediaId)
|
||||
.data("${mediaItem.mediaMetadata.artworkUri}-w$thumbnailSize-h$thumbnailSize")
|
||||
.build(),
|
||||
title = mediaItem.mediaMetadata.title!!.toString(),
|
||||
authors = mediaItem.mediaMetadata.artist.toString(),
|
||||
durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?",
|
||||
menuContent = menuContent,
|
||||
onClick = onClick,
|
||||
onThumbnailContent = onThumbnailContent,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun SongItem(
|
||||
song: SongWithInfo,
|
||||
thumbnailSize: Int,
|
||||
onClick: () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = "${song.song.thumbnailUrl}-w$thumbnailSize-h$thumbnailSize",
|
||||
title = song.song.title,
|
||||
authors = song.authors?.joinToString("") { it.text } ?: "",
|
||||
durationText = song.song.durationText,
|
||||
menuContent = menuContent,
|
||||
onClick = onClick,
|
||||
onThumbnailContent = onThumbnailContent,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun SongItem(
|
||||
thumbnailModel: Any?,
|
||||
title: String,
|
||||
authors: String,
|
||||
durationText: String,
|
||||
onClick: () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
SongItem(
|
||||
title = title,
|
||||
authors = authors,
|
||||
durationText = durationText,
|
||||
onClick = onClick,
|
||||
startContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(54.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = thumbnailModel,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
)
|
||||
|
||||
onThumbnailContent?.invoke(this)
|
||||
}
|
||||
},
|
||||
menuContent = menuContent,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SongItem(
|
||||
title: String,
|
||||
authors: String,
|
||||
durationText: String?,
|
||||
onClick: () -> Unit,
|
||||
startContent: @Composable () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
) {
|
||||
val menuState = LocalMenuState.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.background(backgroundColor ?: colorPalette.background)
|
||||
.padding(start = 16.dp, end = 8.dp)
|
||||
) {
|
||||
startContent()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
text = title,
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText(
|
||||
text = buildString {
|
||||
append(authors)
|
||||
if (authors.isNotEmpty() && durationText != null) {
|
||||
append(" • ")
|
||||
}
|
||||
append(durationText)
|
||||
},
|
||||
style = typography.xs.semiBold.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_vertical),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display(menuContent)
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user