Initial commit

This commit is contained in:
vfsfitvnm
2022-06-02 18:59:18 +02:00
commit 1e673ad582
160 changed files with 10800 additions and 0 deletions

View File

@@ -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 -> {}
}
}
}
}

View File

@@ -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)
)
}
}
}

View 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)
)
}
}

View File

@@ -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)
)
}
}

View 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)
)
}
}