Split PlayerView into many files
This commit is contained in:
parent
c6ee435eef
commit
4a16bc6960
|
@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue