From 4a16bc696049336c923b2590f436bfaa2602329c Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Wed, 3 Aug 2022 11:10:10 +0200 Subject: [PATCH] Split PlayerView into many files --- .../vfsfitvnm/vimusic/ui/views/PlayerView.kt | 802 +----------------- .../vimusic/ui/views/player/Controls.kt | 277 ++++++ .../vimusic/ui/views/player/Lyrics.kt | 271 ++++++ .../vimusic/ui/views/player/StatsForNerds.kt | 228 +++++ .../vimusic/ui/views/player/Thumbnail.kt | 148 ++++ 5 files changed, 926 insertions(+), 800 deletions(-) create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Controls.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/StatsForNerds.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt index 05db4a6..144fb89 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt @@ -1,128 +1,64 @@ package it.vfsfitvnm.vimusic.ui.views -import android.app.SearchManager import android.content.Intent import android.content.res.Configuration import android.media.audiofx.AudioEffect -import android.text.format.DateUtils -import android.text.format.Formatter import android.widget.Toast import androidx.activity.compose.LocalActivityResultRegistryOwner 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.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.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.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.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.verticalScroll -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.saveable.rememberSaveable 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.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset -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.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration 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.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.datasource.cache.Cache -import androidx.media3.datasource.cache.CacheSpan import coil.compose.AsyncImage -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder 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.BottomSheetState 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.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.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.bold -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.rememberError +import it.vfsfitvnm.vimusic.ui.views.player.Controls +import it.vfsfitvnm.vimusic.ui.views.player.Thumbnail import it.vfsfitvnm.vimusic.utils.rememberMediaItem -import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex import it.vfsfitvnm.vimusic.utils.rememberPositionAndDuration -import it.vfsfitvnm.vimusic.utils.rememberRepeatMode import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying -import it.vfsfitvnm.vimusic.utils.rememberVolume import it.vfsfitvnm.vimusic.utils.seamlessPlay import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.vimusic.utils.verticalFadingEdge -import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint 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 @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(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) - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Controls.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Controls.kt new file mode 100644 index 0000000..86db7bd --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Controls.kt @@ -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(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) + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt new file mode 100644 index 0000000..75a47dd --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt @@ -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) + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/StatsForNerds.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/StatsForNerds.kt new file mode 100644 index 0000000..43b6841 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/StatsForNerds.kt @@ -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) + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt new file mode 100644 index 0000000..be87110 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt @@ -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() + } + ) {} + } + } +}