Split PlayerView into many files

This commit is contained in:
vfsfitvnm 2022-08-03 11:10:10 +02:00
parent c6ee435eef
commit 4a16bc6960
5 changed files with 926 additions and 800 deletions

View file

@ -1,128 +1,64 @@
package it.vfsfitvnm.vimusic.ui.views package it.vfsfitvnm.vimusic.ui.views
import android.app.SearchManager
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.media.audiofx.AudioEffect import android.media.audiofx.AudioEffect
import android.text.format.DateUtils
import android.text.format.Formatter
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.with
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
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.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
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.dp
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheSpan
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.BottomSheet import it.vfsfitvnm.vimusic.ui.components.BottomSheet
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.SeekBar
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
import it.vfsfitvnm.vimusic.ui.styling.DarkColorPalette
import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.utils.bold import it.vfsfitvnm.vimusic.ui.views.player.Controls
import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.ui.views.player.Thumbnail
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.rememberError
import it.vfsfitvnm.vimusic.utils.rememberMediaItem import it.vfsfitvnm.vimusic.utils.rememberMediaItem
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
import it.vfsfitvnm.vimusic.utils.rememberPositionAndDuration import it.vfsfitvnm.vimusic.utils.rememberPositionAndDuration
import it.vfsfitvnm.vimusic.utils.rememberRepeatMode
import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying
import it.vfsfitvnm.vimusic.utils.rememberVolume
import it.vfsfitvnm.vimusic.utils.seamlessPlay import it.vfsfitvnm.vimusic.utils.seamlessPlay
import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.thumbnail import it.vfsfitvnm.vimusic.utils.thumbnail
import it.vfsfitvnm.vimusic.utils.verticalFadingEdge
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
@ -418,737 +354,3 @@ fun PlayerView(
) )
} }
} }
@ExperimentalAnimationApi
@Composable
private fun Thumbnail(
isShowingLyrics: Boolean,
onShowLyrics: (Boolean) -> Unit,
isShowingStatsForNerds: Boolean,
onShowStatsForNerds: (Boolean) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier
) {
val binder = LocalPlayerServiceBinder.current
val player = binder?.player ?: return
val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let {
it to (it - 64.dp).px
}
val mediaItemIndex by rememberMediaItemIndex(player)
val error by rememberError(player)
if (error == null) {
AnimatedContent(
targetState = 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)
)
},
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
) { currentMediaItemIndex ->
val mediaItem = remember(currentMediaItemIndex) {
player.getMediaItemAt(currentMediaItemIndex)
}
Box(
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(thumbnailSizeDp)
) {
AsyncImage(
model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onShowLyrics(true)
},
onLongPress = {
onShowStatsForNerds(true)
}
)
}
.fillMaxSize()
)
Lyrics(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingLyrics,
onDismiss = {
onShowLyrics(false)
},
onLyricsUpdate = { mediaId, lyrics ->
if (Database.updateLyrics(mediaId, lyrics) == 0) {
if (mediaId == mediaItem.mediaId) {
Database.insert(mediaItem) { song ->
song.copy(lyrics = lyrics)
}
}
}
},
size = thumbnailSizeDp,
mediaMetadataProvider = mediaItem::mediaMetadata,
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
)
StatsForNerds(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingStatsForNerds,
onDismiss = {
onShowStatsForNerds(false)
},
modifier = Modifier
)
}
}
} else {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.padding(bottom = 32.dp)
.padding(horizontal = 32.dp)
.size(thumbnailSizeDp)
) {
LoadingOrError(
errorMessage = error?.javaClass?.canonicalName,
onRetry = {
player.playWhenReady = true
player.prepare()
}
) {}
}
}
}
@Composable
private fun Lyrics(
mediaId: String,
isDisplayed: Boolean,
onDismiss: () -> Unit,
size: Dp,
mediaMetadataProvider: () -> MediaMetadata,
onLyricsUpdate: (String, String) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier
) {
val (_, typography) = LocalAppearance.current
val context = LocalContext.current
AnimatedVisibility(
visible = isDisplayed,
enter = fadeIn(),
exit = fadeOut(),
) {
var isLoading by remember(mediaId) {
mutableStateOf(false)
}
var isEditingLyrics by remember(mediaId) {
mutableStateOf(false)
}
val lyrics by remember(mediaId) {
Database.lyrics(mediaId).distinctUntilChanged().map flowMap@{ lyrics ->
if (lyrics != null) return@flowMap lyrics
isLoading = true
YouTube.next(mediaId, null)?.map { nextResult ->
nextResult.lyrics?.text()?.map { newLyrics ->
onLyricsUpdate(mediaId, newLyrics ?: "")
isLoading = false
return@flowMap newLyrics ?: ""
}
}
isLoading = false
null
}.distinctUntilChanged()
}.collectAsState(initial = ".", context = Dispatchers.IO)
if (isEditingLyrics) {
TextFieldDialog(
hintText = "Enter the lyrics",
initialTextInput = lyrics ?: "",
singleLine = false,
maxLines = 10,
isTextInputValid = { true },
onDismiss = {
isEditingLyrics = false
},
onDone = {
query {
Database.updateLyrics(mediaId, it)
}
}
)
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onDismiss()
}
)
}
.fillMaxSize()
.background(Color.Black.copy(0.8f))
) {
AnimatedVisibility(
visible = !isLoading && lyrics == null,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
.align(Alignment.TopCenter)
) {
BasicText(
text = "An error has occurred while fetching the lyrics",
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.background(Color.Black.copy(0.4f))
.padding(all = 8.dp)
.fillMaxWidth()
)
}
AnimatedVisibility(
visible = lyrics?.let(String::isEmpty) ?: false,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
.align(Alignment.TopCenter)
) {
BasicText(
text = "Lyrics are not available for this song",
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.background(Color.Black.copy(0.4f))
.padding(all = 8.dp)
.fillMaxWidth()
)
}
if (isLoading) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.shimmer()
) {
repeat(4) { index ->
TextPlaceholder(
modifier = Modifier
.alpha(1f - index * 0.05f)
)
}
}
} else {
lyrics?.let { lyrics ->
if (lyrics.isNotEmpty()) {
BasicText(
text = lyrics,
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.nestedScroll(remember { nestedScrollConnectionProvider() })
.verticalFadingEdge()
.verticalScroll(rememberScrollState())
.padding(vertical = size / 4, horizontal = 32.dp)
)
}
val menuState = LocalMenuState.current
Image(
painter = painterResource(R.drawable.ellipsis_horizontal),
contentDescription = null,
colorFilter = ColorFilter.tint(DarkColorPalette.text),
modifier = Modifier
.padding(all = 4.dp)
.clickable {
menuState.display {
Menu {
MenuEntry(
icon = R.drawable.pencil,
text = "Edit lyrics",
onClick = {
menuState.hide()
isEditingLyrics = true
}
)
MenuEntry(
icon = R.drawable.search,
text = "Search lyrics online",
onClick = {
menuState.hide()
val mediaMetadata = mediaMetadataProvider()
val intent =
Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(
SearchManager.QUERY,
"${mediaMetadata.title} ${mediaMetadata.artist} lyrics"
)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
Toast
.makeText(
context,
"No browser app found!",
Toast.LENGTH_SHORT
)
.show()
}
}
)
MenuEntry(
icon = R.drawable.download,
text = "Fetch lyrics again",
onClick = {
menuState.hide()
query {
Database.updateLyrics(mediaId, null)
}
}
)
}
}
}
.padding(all = 8.dp)
.size(20.dp)
.align(Alignment.BottomEnd)
)
}
}
}
}
}
@Composable
private fun StatsForNerds(
mediaId: String,
isDisplayed: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val (_, typography) = LocalAppearance.current
val context = LocalContext.current
val binder = LocalPlayerServiceBinder.current ?: return
AnimatedVisibility(
visible = isDisplayed,
enter = fadeIn(),
exit = fadeOut(),
) {
var cachedBytes by remember(mediaId) {
mutableStateOf(binder.cache.getCachedBytes(mediaId, 0, -1))
}
val format by remember(mediaId) {
Database.format(mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
val volume by rememberVolume(binder.player)
DisposableEffect(mediaId) {
val listener = object : Cache.Listener {
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
cachedBytes += span.length
}
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
cachedBytes -= span.length
}
override fun onSpanTouched(
cache: Cache,
oldSpan: CacheSpan,
newSpan: CacheSpan
) = Unit
}
binder.cache.addListener(mediaId, listener)
onDispose {
binder.cache.removeListener(mediaId, listener)
}
}
Box(
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onDismiss()
}
)
}
.background(Color.Black.copy(alpha = 0.8f))
.fillMaxSize()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.align(Alignment.Center)
.padding(all = 16.dp)
) {
Column(horizontalAlignment = Alignment.End) {
BasicText(
text = "Id",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Volume",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Loudness",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Bitrate",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Size",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Cached",
style = typography.xs.medium.color(BlackColorPalette.text)
)
}
Column {
BasicText(
text = mediaId,
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "${volume.times(100).roundToInt()}%",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.loudnessDb?.let { loudnessDb ->
"%.2f dB".format(loudnessDb)
} ?: "Unknown",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.bitrate?.let { bitrate ->
"${bitrate / 1000} kbps"
} ?: "Unknown",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.contentLength?.let { contentLength ->
Formatter.formatShortFileSize(
context,
contentLength
)
} ?: "Unknown",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = buildString {
append(Formatter.formatShortFileSize(context, cachedBytes))
format?.contentLength?.let { contentLength ->
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
}
},
style = typography.xs.medium.color(BlackColorPalette.text)
)
}
}
if (format != null && format?.itag == null) {
BasicText(
text = "FETCH MISSING DATA",
style = typography.xxs.medium.color(BlackColorPalette.text),
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = {
query {
runBlocking(Dispatchers.IO) {
YouTube
.player(mediaId)
?.map { response ->
response.streamingData?.adaptiveFormats
?.findLast { format ->
format.itag == 251 || format.itag == 140
}
?.let { format ->
it.vfsfitvnm.vimusic.models.Format(
songId = mediaId,
itag = format.itag,
mimeType = format.mimeType,
bitrate = format.bitrate,
loudnessDb = response.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
contentLength = format.contentLength,
lastModified = format.lastModified
)
}
}
}
?.getOrNull()
?.let(Database::insert)
}
}
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.align(Alignment.BottomEnd)
)
}
}
}
}
@Composable
private fun Controls(
mediaItem: MediaItem,
shouldBePlaying: Boolean,
position: Long,
duration: Long,
modifier: Modifier = Modifier
) {
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
binder?.player ?: return
val repeatMode by rememberRepeatMode(binder.player)
var scrubbingPosition by remember(mediaItem.mediaId) {
mutableStateOf<Long?>(null)
}
val likedAt by remember(mediaItem.mediaId) {
Database.likedAt(mediaItem.mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
val playPauseRoundness by animateDpAsState(if (shouldBePlaying) 32.dp else 16.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
) {
Spacer(
modifier = Modifier
.weight(1f)
)
BasicText(
text = mediaItem.mediaMetadata.title?.toString() ?: "",
style = typography.l.bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
BasicText(
text = mediaItem.mediaMetadata.artist?.toString() ?: "",
style = typography.s.semiBold.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(
modifier = Modifier
.weight(0.5f)
)
SeekBar(
value = scrubbingPosition ?: position,
minimumValue = 0,
maximumValue = duration,
onDragStart = {
scrubbingPosition = it
},
onDrag = { delta ->
scrubbingPosition = if (duration != C.TIME_UNSET) {
scrubbingPosition?.plus(delta)?.coerceIn(0, duration)
} else {
null
}
},
onDragEnd = {
scrubbingPosition?.let(binder.player::seekTo)
scrubbingPosition = null
},
color = colorPalette.text,
backgroundColor = colorPalette.backgroundContainer,
shape = RoundedCornerShape(8.dp)
)
Spacer(
modifier = Modifier
.height(8.dp)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
BasicText(
text = DateUtils.formatElapsedTime((scrubbingPosition ?: position) / 1000),
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (duration != C.TIME_UNSET) {
BasicText(
text = DateUtils.formatElapsedTime(duration / 1000),
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(
modifier = Modifier
.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
Image(
painter = painterResource(R.drawable.heart),
contentDescription = null,
colorFilter = ColorFilter.tint(if (likedAt != null) colorPalette.red else colorPalette.textDisabled),
modifier = Modifier
.clickable {
query {
if (Database.like(
mediaItem.mediaId,
if (likedAt == null) System.currentTimeMillis() else null
) == 0
) {
Database.insert(mediaItem, Song::toggleLike)
}
}
}
.weight(1f)
.size(24.dp)
)
Image(
painter = painterResource(R.drawable.play_skip_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = binder.player::seekToPrevious)
.weight(1f)
.size(24.dp)
)
Spacer(
modifier = Modifier
.width(8.dp)
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(playPauseRoundness))
.clickable {
if (shouldBePlaying) {
binder.player.pause()
} else {
if (binder.player.playbackState == Player.STATE_IDLE) {
binder.player.prepare()
}
binder.player.play()
}
}
.background(color = colorPalette.backgroundContainer)
.size(64.dp)
) {
Image(
painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.align(Alignment.Center)
.size(28.dp)
)
}
Spacer(
modifier = Modifier
.width(8.dp)
)
Image(
painter = painterResource(R.drawable.play_skip_forward),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = binder.player::seekToNext)
.weight(1f)
.size(24.dp)
)
Image(
painter = painterResource(
if (repeatMode == Player.REPEAT_MODE_ONE) {
R.drawable.repeat_one
} else {
R.drawable.repeat
}
),
contentDescription = null,
colorFilter = ColorFilter.tint(
if (repeatMode == Player.REPEAT_MODE_OFF) {
colorPalette.textDisabled
} else {
colorPalette.text
}
),
modifier = Modifier
.clickable {
binder.player.repeatMode
.plus(2)
.mod(3)
.let {
binder.player.repeatMode = it
}
}
.weight(1f)
.size(24.dp)
)
}
Spacer(
modifier = Modifier
.weight(1f)
)
}
}

View file

@ -0,0 +1,277 @@
package it.vfsfitvnm.vimusic.ui.views.player
import android.text.format.DateUtils
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.SeekBar
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.bold
import it.vfsfitvnm.vimusic.utils.rememberRepeatMode
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun Controls(
mediaItem: MediaItem,
shouldBePlaying: Boolean,
position: Long,
duration: Long,
modifier: Modifier = Modifier
) {
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
binder?.player ?: return
val repeatMode by rememberRepeatMode(binder.player)
var scrubbingPosition by remember(mediaItem.mediaId) {
mutableStateOf<Long?>(null)
}
val likedAt by remember(mediaItem.mediaId) {
Database.likedAt(mediaItem.mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
val playPauseRoundness by animateDpAsState(if (shouldBePlaying) 32.dp else 16.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
) {
Spacer(
modifier = Modifier
.weight(1f)
)
BasicText(
text = mediaItem.mediaMetadata.title?.toString() ?: "",
style = typography.l.bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
BasicText(
text = mediaItem.mediaMetadata.artist?.toString() ?: "",
style = typography.s.semiBold.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(
modifier = Modifier
.weight(0.5f)
)
SeekBar(
value = scrubbingPosition ?: position,
minimumValue = 0,
maximumValue = duration,
onDragStart = {
scrubbingPosition = it
},
onDrag = { delta ->
scrubbingPosition = if (duration != C.TIME_UNSET) {
scrubbingPosition?.plus(delta)?.coerceIn(0, duration)
} else {
null
}
},
onDragEnd = {
scrubbingPosition?.let(binder.player::seekTo)
scrubbingPosition = null
},
color = colorPalette.text,
backgroundColor = colorPalette.backgroundContainer,
shape = RoundedCornerShape(8.dp)
)
Spacer(
modifier = Modifier
.height(8.dp)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
BasicText(
text = DateUtils.formatElapsedTime((scrubbingPosition ?: position) / 1000),
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (duration != C.TIME_UNSET) {
BasicText(
text = DateUtils.formatElapsedTime(duration / 1000),
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(
modifier = Modifier
.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
Image(
painter = painterResource(R.drawable.heart),
contentDescription = null,
colorFilter = ColorFilter.tint(if (likedAt != null) colorPalette.red else colorPalette.textDisabled),
modifier = Modifier
.clickable {
query {
if (Database.like(
mediaItem.mediaId,
if (likedAt == null) System.currentTimeMillis() else null
) == 0
) {
Database.insert(mediaItem, Song::toggleLike)
}
}
}
.weight(1f)
.size(24.dp)
)
Image(
painter = painterResource(R.drawable.play_skip_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = binder.player::seekToPrevious)
.weight(1f)
.size(24.dp)
)
Spacer(
modifier = Modifier
.width(8.dp)
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(playPauseRoundness))
.clickable {
if (shouldBePlaying) {
binder.player.pause()
} else {
if (binder.player.playbackState == Player.STATE_IDLE) {
binder.player.prepare()
}
binder.player.play()
}
}
.background(color = colorPalette.backgroundContainer)
.size(64.dp)
) {
Image(
painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.align(Alignment.Center)
.size(28.dp)
)
}
Spacer(
modifier = Modifier
.width(8.dp)
)
Image(
painter = painterResource(R.drawable.play_skip_forward),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = binder.player::seekToNext)
.weight(1f)
.size(24.dp)
)
Image(
painter = painterResource(
if (repeatMode == Player.REPEAT_MODE_ONE) {
R.drawable.repeat_one
} else {
R.drawable.repeat
}
),
contentDescription = null,
colorFilter = ColorFilter.tint(
if (repeatMode == Player.REPEAT_MODE_OFF) {
colorPalette.textDisabled
} else {
colorPalette.text
}
),
modifier = Modifier
.clickable {
binder.player.repeatMode
.plus(2)
.mod(3)
.let {
binder.player.repeatMode = it
}
}
.weight(1f)
.size(24.dp)
)
}
Spacer(
modifier = Modifier
.weight(1f)
)
}
}

View file

@ -0,0 +1,271 @@
package it.vfsfitvnm.vimusic.ui.views.player
import android.app.SearchManager
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaMetadata
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
import it.vfsfitvnm.vimusic.ui.styling.DarkColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.verticalFadingEdge
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@Composable
fun Lyrics(
mediaId: String,
isDisplayed: Boolean,
onDismiss: () -> Unit,
size: Dp,
mediaMetadataProvider: () -> MediaMetadata,
onLyricsUpdate: (String, String) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier
) {
val (_, typography) = LocalAppearance.current
val context = LocalContext.current
AnimatedVisibility(
visible = isDisplayed,
enter = fadeIn(),
exit = fadeOut(),
) {
var isLoading by remember(mediaId) {
mutableStateOf(false)
}
var isEditingLyrics by remember(mediaId) {
mutableStateOf(false)
}
val lyrics by remember(mediaId) {
Database.lyrics(mediaId).distinctUntilChanged().map flowMap@{ lyrics ->
if (lyrics != null) return@flowMap lyrics
isLoading = true
YouTube.next(mediaId, null)?.map { nextResult ->
nextResult.lyrics?.text()?.map { newLyrics ->
onLyricsUpdate(mediaId, newLyrics ?: "")
isLoading = false
return@flowMap newLyrics ?: ""
}
}
isLoading = false
null
}.distinctUntilChanged()
}.collectAsState(initial = ".", context = Dispatchers.IO)
if (isEditingLyrics) {
TextFieldDialog(
hintText = "Enter the lyrics",
initialTextInput = lyrics ?: "",
singleLine = false,
maxLines = 10,
isTextInputValid = { true },
onDismiss = {
isEditingLyrics = false
},
onDone = {
query {
Database.updateLyrics(mediaId, it)
}
}
)
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onDismiss()
}
)
}
.fillMaxSize()
.background(Color.Black.copy(0.8f))
) {
AnimatedVisibility(
visible = !isLoading && lyrics == null,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
.align(Alignment.TopCenter)
) {
BasicText(
text = "An error has occurred while fetching the lyrics",
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.background(Color.Black.copy(0.4f))
.padding(all = 8.dp)
.fillMaxWidth()
)
}
AnimatedVisibility(
visible = lyrics?.let(String::isEmpty) ?: false,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
.align(Alignment.TopCenter)
) {
BasicText(
text = "Lyrics are not available for this song",
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.background(Color.Black.copy(0.4f))
.padding(all = 8.dp)
.fillMaxWidth()
)
}
if (isLoading) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.shimmer()
) {
repeat(4) { index ->
TextPlaceholder(
modifier = Modifier
.alpha(1f - index * 0.05f)
)
}
}
} else {
lyrics?.let { lyrics ->
if (lyrics.isNotEmpty()) {
BasicText(
text = lyrics,
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.nestedScroll(remember { nestedScrollConnectionProvider() })
.verticalFadingEdge()
.verticalScroll(rememberScrollState())
.padding(vertical = size / 4, horizontal = 32.dp)
)
}
val menuState = LocalMenuState.current
Image(
painter = painterResource(R.drawable.ellipsis_horizontal),
contentDescription = null,
colorFilter = ColorFilter.tint(DarkColorPalette.text),
modifier = Modifier
.padding(all = 4.dp)
.clickable {
menuState.display {
Menu {
MenuEntry(
icon = R.drawable.pencil,
text = "Edit lyrics",
onClick = {
menuState.hide()
isEditingLyrics = true
}
)
MenuEntry(
icon = R.drawable.search,
text = "Search lyrics online",
onClick = {
menuState.hide()
val mediaMetadata = mediaMetadataProvider()
val intent =
Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(
SearchManager.QUERY,
"${mediaMetadata.title} ${mediaMetadata.artist} lyrics"
)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
Toast.makeText(
context,
"No browser app found!",
Toast.LENGTH_SHORT
)
.show()
}
}
)
MenuEntry(
icon = R.drawable.download,
text = "Fetch lyrics again",
onClick = {
menuState.hide()
query {
Database.updateLyrics(mediaId, null)
}
}
)
}
}
}
.padding(all = 8.dp)
.size(20.dp)
.align(Alignment.BottomEnd)
)
}
}
}
}
}

View file

@ -0,0 +1,228 @@
package it.vfsfitvnm.vimusic.ui.views.player
import android.text.format.Formatter
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheSpan
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.models.Format
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.rememberVolume
import it.vfsfitvnm.youtubemusic.YouTube
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
@Composable
fun StatsForNerds(
mediaId: String,
isDisplayed: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val (_, typography) = LocalAppearance.current
val context = LocalContext.current
val binder = LocalPlayerServiceBinder.current ?: return
AnimatedVisibility(
visible = isDisplayed,
enter = fadeIn(),
exit = fadeOut(),
) {
var cachedBytes by remember(mediaId) {
mutableStateOf(binder.cache.getCachedBytes(mediaId, 0, -1))
}
val format by remember(mediaId) {
Database.format(mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
val volume by rememberVolume(binder.player)
DisposableEffect(mediaId) {
val listener = object : Cache.Listener {
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
cachedBytes += span.length
}
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
cachedBytes -= span.length
}
override fun onSpanTouched(
cache: Cache,
oldSpan: CacheSpan,
newSpan: CacheSpan
) = Unit
}
binder.cache.addListener(mediaId, listener)
onDispose {
binder.cache.removeListener(mediaId, listener)
}
}
Box(
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onDismiss()
}
)
}
.background(Color.Black.copy(alpha = 0.8f))
.fillMaxSize()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.align(Alignment.Center)
.padding(all = 16.dp)
) {
Column(horizontalAlignment = Alignment.End) {
BasicText(
text = "Id",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Volume",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Loudness",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Bitrate",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Size",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Cached",
style = typography.xs.medium.color(BlackColorPalette.text)
)
}
Column {
BasicText(
text = mediaId,
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "${volume.times(100).roundToInt()}%",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.loudnessDb?.let { loudnessDb ->
"%.2f dB".format(loudnessDb)
} ?: "Unknown",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.bitrate?.let { bitrate ->
"${bitrate / 1000} kbps"
} ?: "Unknown",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.contentLength?.let { contentLength ->
Formatter.formatShortFileSize(
context,
contentLength
)
} ?: "Unknown",
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = buildString {
append(Formatter.formatShortFileSize(context, cachedBytes))
format?.contentLength?.let { contentLength ->
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
}
},
style = typography.xs.medium.color(BlackColorPalette.text)
)
}
}
if (format != null && format?.itag == null) {
BasicText(
text = "FETCH MISSING DATA",
style = typography.xxs.medium.color(BlackColorPalette.text),
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = {
query {
runBlocking(Dispatchers.IO) {
YouTube.player(mediaId)
?.map { response ->
response.streamingData?.adaptiveFormats
?.findLast { format ->
format.itag == 251 || format.itag == 140
}
?.let { format ->
Format(
songId = mediaId,
itag = format.itag,
mimeType = format.mimeType,
bitrate = format.bitrate,
loudnessDb = response.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
contentLength = format.contentLength,
lastModified = format.lastModified
)
}
}
}
?.getOrNull()
?.let(Database::insert)
}
}
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.align(Alignment.BottomEnd)
)
}
}
}
}

View file

@ -0,0 +1,148 @@
package it.vfsfitvnm.vimusic.ui.views.player
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.with
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.utils.rememberError
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
import it.vfsfitvnm.vimusic.utils.thumbnail
@ExperimentalAnimationApi
@Composable
fun Thumbnail(
isShowingLyrics: Boolean,
onShowLyrics: (Boolean) -> Unit,
isShowingStatsForNerds: Boolean,
onShowStatsForNerds: (Boolean) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier
) {
val binder = LocalPlayerServiceBinder.current
val player = binder?.player ?: return
val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let {
it to (it - 64.dp).px
}
val mediaItemIndex by rememberMediaItemIndex(player)
val error by rememberError(player)
if (error == null) {
AnimatedContent(
targetState = 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)
)
},
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
) { currentMediaItemIndex ->
val mediaItem = remember(currentMediaItemIndex) {
player.getMediaItemAt(currentMediaItemIndex)
}
Box(
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(thumbnailSizeDp)
) {
AsyncImage(
model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onShowLyrics(true)
},
onLongPress = {
onShowStatsForNerds(true)
}
)
}
.fillMaxSize()
)
Lyrics(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingLyrics,
onDismiss = {
onShowLyrics(false)
},
onLyricsUpdate = { mediaId, lyrics ->
if (Database.updateLyrics(mediaId, lyrics) == 0) {
if (mediaId == mediaItem.mediaId) {
Database.insert(mediaItem) { song ->
song.copy(lyrics = lyrics)
}
}
}
},
size = thumbnailSizeDp,
mediaMetadataProvider = mediaItem::mediaMetadata,
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
)
StatsForNerds(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingStatsForNerds,
onDismiss = {
onShowStatsForNerds(false)
},
modifier = Modifier
)
}
}
} else {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.padding(bottom = 32.dp)
.padding(horizontal = 32.dp)
.size(thumbnailSizeDp)
) {
LoadingOrError(
errorMessage = error?.javaClass?.canonicalName,
onRetry = {
player.playWhenReady = true
player.prepare()
}
) {}
}
}
}