|
@@ -1,6 +1,7 @@
|
|
|
package it.vfsfitvnm.vimusic.ui.views
|
|
|
|
|
|
import android.content.Intent
|
|
|
+import android.content.res.Configuration
|
|
|
import android.media.audiofx.AudioEffect
|
|
|
import android.text.format.DateUtils
|
|
|
import android.text.format.Formatter
|
|
@@ -12,12 +13,10 @@ 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.*
|
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
|
import androidx.compose.foundation.text.BasicText
|
|
|
-import androidx.compose.material.ripple.rememberRipple
|
|
|
import androidx.compose.runtime.*
|
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
|
import androidx.compose.ui.Alignment
|
|
@@ -30,6 +29,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
|
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
|
|
@@ -50,11 +50,8 @@ import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu
|
|
|
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError
|
|
|
import it.vfsfitvnm.vimusic.ui.styling.*
|
|
|
import it.vfsfitvnm.vimusic.utils.*
|
|
|
-import it.vfsfitvnm.youtubemusic.YouTube
|
|
|
-import it.vfsfitvnm.youtubemusic.models.PlayerResponse
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
|
-import kotlinx.coroutines.launch
|
|
|
import kotlin.math.roundToInt
|
|
|
|
|
|
|
|
@@ -65,11 +62,12 @@ fun PlayerView(
|
|
|
modifier: Modifier = Modifier,
|
|
|
) {
|
|
|
val menuState = LocalMenuState.current
|
|
|
- val preferences = LocalPreferences.current
|
|
|
+
|
|
|
val colorPalette = LocalColorPalette.current
|
|
|
val typography = LocalTypography.current
|
|
|
val binder = LocalPlayerServiceBinder.current
|
|
|
val context = LocalContext.current
|
|
|
+ val configuration = LocalConfiguration.current
|
|
|
|
|
|
val player = binder?.player
|
|
|
val playerState = rememberPlayerState(player)
|
|
@@ -77,11 +75,6 @@ fun PlayerView(
|
|
|
player ?: return
|
|
|
playerState?.mediaItem ?: return
|
|
|
|
|
|
- val coroutineScope = rememberCoroutineScope()
|
|
|
-
|
|
|
- val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let {
|
|
|
- it to (it - 64.dp).px
|
|
|
- }
|
|
|
|
|
|
BottomSheet(
|
|
|
state = layoutState,
|
|
@@ -177,477 +170,579 @@ fun PlayerView(
|
|
|
playerState.mediaItem.mediaId.let(Database::song).distinctUntilChanged()
|
|
|
}.collectAsState(initial = null, context = Dispatchers.IO)
|
|
|
|
|
|
- var isShowingStatsForNerds by rememberSaveable {
|
|
|
- mutableStateOf(false)
|
|
|
+ when (configuration.orientation) {
|
|
|
+ Configuration.ORIENTATION_LANDSCAPE -> {
|
|
|
+ Row(
|
|
|
+ verticalAlignment = Alignment.CenterVertically,
|
|
|
+ modifier = Modifier
|
|
|
+ .padding(bottom = 64.dp)
|
|
|
+ .background(colorPalette.background)
|
|
|
+ .padding(top = 16.dp)
|
|
|
+ ) {
|
|
|
+ Box(
|
|
|
+ contentAlignment = Alignment.Center,
|
|
|
+ modifier = Modifier
|
|
|
+ .weight(0.66f)
|
|
|
+ .padding(horizontal = 16.dp)
|
|
|
+ .padding(bottom = 16.dp)
|
|
|
+ ) {
|
|
|
+ Thumbnail(
|
|
|
+ playerState = playerState,
|
|
|
+ song = song,
|
|
|
+ modifier = Modifier
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ Controls(
|
|
|
+ playerState = playerState,
|
|
|
+ song = song,
|
|
|
+ modifier = Modifier
|
|
|
+ .padding(vertical = 8.dp)
|
|
|
+ .fillMaxHeight()
|
|
|
+ .weight(1f)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else -> {
|
|
|
+ Column(
|
|
|
+ horizontalAlignment = Alignment.CenterHorizontally,
|
|
|
+ modifier = Modifier
|
|
|
+ .padding(bottom = 64.dp)
|
|
|
+ .background(colorPalette.background)
|
|
|
+ .padding(top = 16.dp)
|
|
|
+ ) {
|
|
|
+ Box(
|
|
|
+ contentAlignment = Alignment.Center,
|
|
|
+ modifier = Modifier
|
|
|
+ .weight(1.25f)
|
|
|
+ .padding(horizontal = 32.dp, vertical = 8.dp)
|
|
|
+ ) {
|
|
|
+ Thumbnail(
|
|
|
+ playerState = playerState,
|
|
|
+ song = song,
|
|
|
+ modifier = Modifier
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ Controls(
|
|
|
+ playerState = playerState,
|
|
|
+ song = song,
|
|
|
+ modifier = Modifier
|
|
|
+ .padding(vertical = 8.dp)
|
|
|
+ .fillMaxWidth()
|
|
|
+ .weight(1f)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ TopAppBar {
|
|
|
+ Spacer(
|
|
|
+ modifier = Modifier
|
|
|
+ .padding(horizontal = 16.dp, vertical = 8.dp)
|
|
|
+ .size(24.dp)
|
|
|
+ )
|
|
|
+
|
|
|
+ Image(
|
|
|
+ painter = painterResource(R.drawable.ellipsis_horizontal),
|
|
|
+ contentDescription = null,
|
|
|
+ colorFilter = ColorFilter.tint(colorPalette.text),
|
|
|
+ modifier = Modifier
|
|
|
+ .clickable {
|
|
|
+ menuState.display {
|
|
|
+ val resultRegistryOwner = LocalActivityResultRegistryOwner.current
|
|
|
+
|
|
|
+ BaseMediaItemMenu(
|
|
|
+ mediaItem = playerState.mediaItem,
|
|
|
+ onGoToEqualizer = {
|
|
|
+ val intent =
|
|
|
+ Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
|
|
|
+ putExtra(
|
|
|
+ AudioEffect.EXTRA_AUDIO_SESSION,
|
|
|
+ player.audioSessionId
|
|
|
+ )
|
|
|
+ putExtra(
|
|
|
+ AudioEffect.EXTRA_PACKAGE_NAME,
|
|
|
+ context.packageName
|
|
|
+ )
|
|
|
+ putExtra(
|
|
|
+ AudioEffect.EXTRA_CONTENT_TYPE,
|
|
|
+ AudioEffect.CONTENT_TYPE_MUSIC
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (intent.resolveActivity(context.packageManager) != null) {
|
|
|
+ val contract =
|
|
|
+ ActivityResultContracts.StartActivityForResult()
|
|
|
+
|
|
|
+ resultRegistryOwner?.activityResultRegistry
|
|
|
+ ?.register("", contract) {}
|
|
|
+ ?.launch(intent)
|
|
|
+ } else {
|
|
|
+ Toast
|
|
|
+ .makeText(
|
|
|
+ context,
|
|
|
+ "No equalizer app found!",
|
|
|
+ Toast.LENGTH_SHORT
|
|
|
+ )
|
|
|
+ .show()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onDismiss = menuState::hide,
|
|
|
+ onGlobalRouteEmitted = layoutState.collapse,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(horizontal = 16.dp, vertical = 8.dp)
|
|
|
+ .size(24.dp)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
- Column(
|
|
|
- horizontalAlignment = Alignment.CenterHorizontally,
|
|
|
+
|
|
|
+ PlayerBottomSheet(
|
|
|
+ playerState = playerState,
|
|
|
+ layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound * 0.9f),
|
|
|
+ onGlobalRouteEmitted = layoutState.collapse,
|
|
|
+ padding = layoutState.upperBound * 0.1f,
|
|
|
+ song = song,
|
|
|
modifier = Modifier
|
|
|
- .background(colorPalette.background)
|
|
|
- .padding(bottom = 72.dp)
|
|
|
- .fillMaxSize()
|
|
|
+ .align(Alignment.BottomCenter)
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@ExperimentalAnimationApi
|
|
|
+@Composable
|
|
|
+private fun Thumbnail(
|
|
|
+ playerState: PlayerState,
|
|
|
+ song: Song?,
|
|
|
+ modifier: Modifier = Modifier
|
|
|
+) {
|
|
|
+ val typography = LocalTypography.current
|
|
|
+ val context = LocalContext.current
|
|
|
+ val binder = LocalPlayerServiceBinder.current
|
|
|
+ val player = binder?.player ?: return
|
|
|
+
|
|
|
+ playerState.mediaItem ?: return
|
|
|
+
|
|
|
+ val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let {
|
|
|
+ it to (it - 64.dp).px
|
|
|
+ }
|
|
|
+
|
|
|
+ var isShowingStatsForNerds by rememberSaveable {
|
|
|
+ mutableStateOf(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (playerState.error == null) {
|
|
|
+ AnimatedContent(
|
|
|
+ targetState = playerState.mediaItemIndex,
|
|
|
+ transitionSpec = {
|
|
|
+ val slideDirection =
|
|
|
+ if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
|
|
+
|
|
|
+ (slideIntoContainer(slideDirection) + fadeIn() with
|
|
|
+ slideOutOfContainer(slideDirection) + fadeOut()).using(
|
|
|
+ SizeTransform(clip = false)
|
|
|
+ )
|
|
|
+ },
|
|
|
+ modifier = modifier
|
|
|
+ .aspectRatio(1f)
|
|
|
) {
|
|
|
- var scrubbingPosition by remember(playerState.mediaItemIndex) {
|
|
|
- mutableStateOf<Long?>(null)
|
|
|
+ val artworkUri = remember(it) {
|
|
|
+ player.getMediaItemAt(it).mediaMetadata.artworkUri.thumbnail(
|
|
|
+ thumbnailSizePx
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
- TopAppBar {
|
|
|
- Spacer(
|
|
|
+ Box(
|
|
|
+ modifier = Modifier
|
|
|
+ .clip(ThumbnailRoundness.shape)
|
|
|
+ .size(thumbnailSizeDp)
|
|
|
+ ) {
|
|
|
+ AsyncImage(
|
|
|
+ model = artworkUri,
|
|
|
+ contentDescription = null,
|
|
|
+ contentScale = ContentScale.Crop,
|
|
|
modifier = Modifier
|
|
|
- .padding(horizontal = 16.dp, vertical = 8.dp)
|
|
|
- .size(24.dp)
|
|
|
+ .pointerInput(Unit) {
|
|
|
+ detectTapGestures(
|
|
|
+ onLongPress = {
|
|
|
+ isShowingStatsForNerds = true
|
|
|
+ }
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .fillMaxSize()
|
|
|
)
|
|
|
|
|
|
- Image(
|
|
|
- painter = painterResource(R.drawable.ellipsis_horizontal),
|
|
|
- contentDescription = null,
|
|
|
- colorFilter = ColorFilter.tint(colorPalette.text),
|
|
|
- modifier = Modifier
|
|
|
- .clickable {
|
|
|
- menuState.display {
|
|
|
- val resultRegistryOwner = LocalActivityResultRegistryOwner.current
|
|
|
-
|
|
|
- BaseMediaItemMenu(
|
|
|
- mediaItem = playerState.mediaItem,
|
|
|
- onGoToEqualizer = {
|
|
|
- val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
|
|
|
- putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId)
|
|
|
- putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
|
|
- putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
|
|
- }
|
|
|
+ AnimatedVisibility(
|
|
|
+ visible = isShowingStatsForNerds,
|
|
|
+ enter = fadeIn(),
|
|
|
+ exit = fadeOut(),
|
|
|
+ ) {
|
|
|
+ var cachedBytes by remember(song?.id) {
|
|
|
+ mutableStateOf(binder.cache.getCachedBytes(playerState.mediaItem.mediaId, 0, -1))
|
|
|
+ }
|
|
|
|
|
|
- if (intent.resolveActivity(context.packageManager) != null) {
|
|
|
- val contract = ActivityResultContracts.StartActivityForResult()
|
|
|
+ val loudnessDb by remember {
|
|
|
+ derivedStateOf {
|
|
|
+ song?.loudnessDb ?: playerState.mediaMetadata.extras?.getFloatOrNull("loudnessDb")
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- resultRegistryOwner?.activityResultRegistry?.register("", contract) {}?.launch(intent)
|
|
|
- } else {
|
|
|
- Toast.makeText(context, "No equalizer app found!", Toast.LENGTH_SHORT).show()
|
|
|
- }
|
|
|
- },
|
|
|
- onDismiss = menuState::hide,
|
|
|
- onGlobalRouteEmitted = layoutState.collapse,
|
|
|
- )
|
|
|
+ val contentLength by remember {
|
|
|
+ derivedStateOf {
|
|
|
+ song?.contentLength ?: playerState.mediaMetadata.extras?.getLongOrNull("contentLength")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ DisposableEffect(song?.id) {
|
|
|
+ val key = playerState.mediaItem.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
|
|
|
}
|
|
|
- .padding(horizontal = 16.dp, vertical = 8.dp)
|
|
|
- .size(24.dp)
|
|
|
- )
|
|
|
- }
|
|
|
|
|
|
- if (playerState.error == null) {
|
|
|
- AnimatedContent(
|
|
|
- targetState = playerState.mediaItemIndex,
|
|
|
- transitionSpec = {
|
|
|
- val slideDirection =
|
|
|
- if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
|
|
+ binder.cache.addListener(key, listener)
|
|
|
|
|
|
- (slideIntoContainer(slideDirection) + fadeIn() with
|
|
|
- slideOutOfContainer(slideDirection) + fadeOut()).using(
|
|
|
- SizeTransform(clip = false)
|
|
|
- )
|
|
|
- },
|
|
|
- modifier = Modifier
|
|
|
- .weight(1f)
|
|
|
- .align(Alignment.CenterHorizontally)
|
|
|
- ) {
|
|
|
- val artworkUri = remember(it) {
|
|
|
- player.getMediaItemAt(it).mediaMetadata.artworkUri.thumbnail(
|
|
|
- thumbnailSizePx
|
|
|
- )
|
|
|
+ onDispose {
|
|
|
+ binder.cache.removeListener(key, listener)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- Box(
|
|
|
+ Column(
|
|
|
+ verticalArrangement = Arrangement.SpaceBetween,
|
|
|
modifier = Modifier
|
|
|
- .padding(bottom = 32.dp)
|
|
|
- .padding(horizontal = 32.dp)
|
|
|
- .aspectRatio(1f)
|
|
|
- .clip(ThumbnailRoundness.shape)
|
|
|
- .size(thumbnailSizeDp)
|
|
|
+ .pointerInput(Unit) {
|
|
|
+ detectTapGestures(
|
|
|
+ onPress = {
|
|
|
+ isShowingStatsForNerds = false
|
|
|
+ }
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .background(Color.Black.copy(alpha = 0.8f))
|
|
|
+ .fillMaxSize()
|
|
|
) {
|
|
|
- AsyncImage(
|
|
|
- model = artworkUri,
|
|
|
- contentDescription = null,
|
|
|
- contentScale = ContentScale.Crop,
|
|
|
+ Row(
|
|
|
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
|
modifier = Modifier
|
|
|
- .pointerInput(Unit) {
|
|
|
- detectTapGestures(
|
|
|
- onLongPress = {
|
|
|
- isShowingStatsForNerds = true
|
|
|
- }
|
|
|
- )
|
|
|
- }
|
|
|
- .fillMaxSize()
|
|
|
- )
|
|
|
-
|
|
|
- androidx.compose.animation.AnimatedVisibility(
|
|
|
- visible = isShowingStatsForNerds,
|
|
|
- enter = fadeIn(),
|
|
|
- exit = fadeOut(),
|
|
|
+ .padding(all = 16.dp)
|
|
|
) {
|
|
|
- var cachedBytes by remember(song?.id) {
|
|
|
- mutableStateOf(binder.cache.getCachedBytes(playerState.mediaItem.mediaId, 0, -1))
|
|
|
+ Column {
|
|
|
+ BasicText(
|
|
|
+ text = "Id",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = "Volume",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = "Loudness",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = "Size",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = "Cached",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
- val loudnessDb by remember {
|
|
|
- derivedStateOf {
|
|
|
- song?.loudnessDb ?: playerState.mediaMetadata.extras?.getFloatOrNull("loudnessDb")
|
|
|
- }
|
|
|
- }
|
|
|
+ Column {
|
|
|
+ BasicText(
|
|
|
+ text = playerState.mediaItem.mediaId,
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = "${playerState.volume.times(100).roundToInt()}%",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = loudnessDb?.let { loudnessDb ->
|
|
|
+ "%.2f dB".format(loudnessDb)
|
|
|
+ } ?: "Unknown",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = contentLength?.let { contentLength ->
|
|
|
+ Formatter.formatShortFileSize(
|
|
|
+ context,
|
|
|
+ contentLength
|
|
|
+ )
|
|
|
+ } ?: "Unknown",
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
+ BasicText(
|
|
|
+ text = buildString {
|
|
|
+ append(Formatter.formatShortFileSize(context, cachedBytes))
|
|
|
|
|
|
- val contentLength by remember {
|
|
|
- derivedStateOf {
|
|
|
- song?.contentLength ?: playerState.mediaMetadata.extras?.getLongOrNull("contentLength")
|
|
|
- }
|
|
|
+ contentLength?.let { contentLength ->
|
|
|
+ append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
|
|
|
+ }
|
|
|
+ },
|
|
|
+ style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
+ )
|
|
|
}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ Box(
|
|
|
+ contentAlignment = Alignment.Center,
|
|
|
+ modifier = modifier
|
|
|
+ .padding(bottom = 32.dp)
|
|
|
+ .padding(horizontal = 32.dp)
|
|
|
+ .size(thumbnailSizeDp)
|
|
|
+ ) {
|
|
|
+ LoadingOrError(
|
|
|
+ errorMessage = playerState.error.javaClass.canonicalName,
|
|
|
+ onRetry = {
|
|
|
+ player.playWhenReady = true
|
|
|
+ player.prepare()
|
|
|
+ }
|
|
|
+ ) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
- DisposableEffect(song?.id) {
|
|
|
- val key = playerState.mediaItem.mediaId
|
|
|
-
|
|
|
- val listener = object : Cache.Listener {
|
|
|
- override fun onSpanAdded(cache: Cache, span: CacheSpan) {
|
|
|
- cachedBytes += span.length
|
|
|
- }
|
|
|
+@Composable
|
|
|
+private fun Controls(
|
|
|
+ playerState: PlayerState,
|
|
|
+ song: Song?,
|
|
|
+ modifier: Modifier = Modifier
|
|
|
+) {
|
|
|
+ val typography = LocalTypography.current
|
|
|
+ val colorPalette = LocalColorPalette.current
|
|
|
+ val preferences = LocalPreferences.current
|
|
|
|
|
|
- override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
|
|
|
- cachedBytes -= span.length
|
|
|
- }
|
|
|
+ val binder = LocalPlayerServiceBinder.current
|
|
|
+ val player = binder?.player ?: return
|
|
|
|
|
|
- override fun onSpanTouched(
|
|
|
- cache: Cache,
|
|
|
- oldSpan: CacheSpan,
|
|
|
- newSpan: CacheSpan
|
|
|
- ) = Unit
|
|
|
- }
|
|
|
+ var scrubbingPosition by remember(playerState.mediaItemIndex) {
|
|
|
+ mutableStateOf<Long?>(null)
|
|
|
+ }
|
|
|
|
|
|
- binder.cache.addListener(key, listener)
|
|
|
+ Column(
|
|
|
+ horizontalAlignment = Alignment.CenterHorizontally,
|
|
|
+ modifier = modifier
|
|
|
+ .fillMaxWidth()
|
|
|
+ .padding(horizontal = 32.dp)
|
|
|
+ ) {
|
|
|
+ Spacer(
|
|
|
+ modifier = Modifier
|
|
|
+ .weight(1f)
|
|
|
+ )
|
|
|
|
|
|
- onDispose {
|
|
|
- binder.cache.removeListener(key, listener)
|
|
|
- }
|
|
|
- }
|
|
|
+ BasicText(
|
|
|
+ text = playerState.mediaMetadata.title?.toString() ?: "",
|
|
|
+ style = typography.l.bold,
|
|
|
+ maxLines = 1,
|
|
|
+ overflow = TextOverflow.Ellipsis
|
|
|
+ )
|
|
|
|
|
|
- Column(
|
|
|
- verticalArrangement = Arrangement.SpaceBetween,
|
|
|
- modifier = Modifier
|
|
|
- .pointerInput(Unit) {
|
|
|
- detectTapGestures(
|
|
|
- onPress = {
|
|
|
- isShowingStatsForNerds = false
|
|
|
- }
|
|
|
- )
|
|
|
- }
|
|
|
- .background(Color.Black.copy(alpha = 0.8f))
|
|
|
- .fillMaxSize()
|
|
|
- ) {
|
|
|
- Row(
|
|
|
- horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
|
- modifier = Modifier
|
|
|
- .padding(all = 16.dp)
|
|
|
- ) {
|
|
|
- Column {
|
|
|
- BasicText(
|
|
|
- text = "Id",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = "Volume",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = "Loudness",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = "Size",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = "Cached",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- }
|
|
|
+ BasicText(
|
|
|
+ text = playerState.mediaMetadata.artist?.toString() ?: "",
|
|
|
+ style = typography.s.semiBold.secondary,
|
|
|
+ maxLines = 1,
|
|
|
+ overflow = TextOverflow.Ellipsis
|
|
|
+ )
|
|
|
|
|
|
- Column {
|
|
|
- BasicText(
|
|
|
- text = playerState.mediaItem.mediaId,
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = "${playerState.volume.times(100).roundToInt()}%",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = loudnessDb?.let { loudnessDb ->
|
|
|
- "%.2f dB".format(loudnessDb)
|
|
|
- } ?: "Unknown",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = contentLength?.let { contentLength ->
|
|
|
- Formatter.formatShortFileSize(
|
|
|
- context,
|
|
|
- contentLength
|
|
|
- )
|
|
|
- } ?: "Unknown",
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- BasicText(
|
|
|
- text = buildString {
|
|
|
- append(Formatter.formatShortFileSize(context, cachedBytes))
|
|
|
-
|
|
|
- contentLength?.let { contentLength ->
|
|
|
- append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
|
|
|
- }
|
|
|
- },
|
|
|
- style = typography.xs.semiBold.color(BlackColorPalette.text)
|
|
|
- )
|
|
|
- }
|
|
|
- }
|
|
|
+ Spacer(
|
|
|
+ modifier = Modifier
|
|
|
+ .weight(0.5f)
|
|
|
+ )
|
|
|
|
|
|
- if (song != null && (contentLength == null || loudnessDb == null)) {
|
|
|
- BasicText(
|
|
|
- text = "FILL MISSING DATA",
|
|
|
- style = typography.xxs.semiBold.color(BlackColorPalette.text),
|
|
|
- modifier = Modifier
|
|
|
- .clickable(
|
|
|
- indication = rememberRipple(bounded = true),
|
|
|
- interactionSource = remember { MutableInteractionSource() },
|
|
|
- onClick = {
|
|
|
- song?.let { song ->
|
|
|
- coroutineScope.launch(Dispatchers.IO) {
|
|
|
- YouTube
|
|
|
- .player(song.id)
|
|
|
- ?.map { body ->
|
|
|
- Database.update(
|
|
|
- song.copy(
|
|
|
- loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
|
|
|
- contentLength = body.streamingData?.adaptiveFormats
|
|
|
- ?.findLast { format ->
|
|
|
- format.itag == 251
|
|
|
- }
|
|
|
- ?.let(PlayerResponse.StreamingData.AdaptiveFormat::contentLength)
|
|
|
- )
|
|
|
- )
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- )
|
|
|
- .padding(all = 16.dp)
|
|
|
- .align(Alignment.End)
|
|
|
- )
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ SeekBar(
|
|
|
+ value = scrubbingPosition ?: playerState.currentPosition,
|
|
|
+ minimumValue = 0,
|
|
|
+ maximumValue = playerState.duration,
|
|
|
+ onDragStart = {
|
|
|
+ scrubbingPosition = it
|
|
|
+ },
|
|
|
+ onDrag = { delta ->
|
|
|
+ scrubbingPosition = if (playerState.duration != C.TIME_UNSET) {
|
|
|
+ scrubbingPosition?.plus(delta)?.coerceIn(0, playerState.duration)
|
|
|
+ } else {
|
|
|
+ null
|
|
|
}
|
|
|
- } else {
|
|
|
- Box(
|
|
|
- contentAlignment = Alignment.Center,
|
|
|
- modifier = Modifier
|
|
|
- .weight(1f)
|
|
|
- .align(Alignment.CenterHorizontally)
|
|
|
- .padding(bottom = 32.dp)
|
|
|
- .padding(horizontal = 32.dp)
|
|
|
- .size(thumbnailSizeDp)
|
|
|
- ) {
|
|
|
- LoadingOrError(
|
|
|
- errorMessage = playerState.error.javaClass.canonicalName,
|
|
|
- onRetry = {
|
|
|
- player.playWhenReady = true
|
|
|
- player.prepare()
|
|
|
- }
|
|
|
- ) {}
|
|
|
- }
|
|
|
- }
|
|
|
+ },
|
|
|
+ onDragEnd = {
|
|
|
+ scrubbingPosition?.let(player::seekTo)
|
|
|
+ scrubbingPosition = null
|
|
|
+ },
|
|
|
+ color = colorPalette.text,
|
|
|
+ backgroundColor = colorPalette.textDisabled,
|
|
|
+ shape = RoundedCornerShape(8.dp)
|
|
|
+ )
|
|
|
|
|
|
- BasicText(
|
|
|
- text = playerState.mediaMetadata.title?.toString() ?: "",
|
|
|
- style = typography.l.bold,
|
|
|
- maxLines = 1,
|
|
|
- overflow = TextOverflow.Ellipsis,
|
|
|
- modifier = Modifier
|
|
|
- .padding(horizontal = 32.dp)
|
|
|
- )
|
|
|
+ Spacer(
|
|
|
+ modifier = Modifier
|
|
|
+ .height(8.dp)
|
|
|
+ )
|
|
|
|
|
|
+ Row(
|
|
|
+ horizontalArrangement = Arrangement.SpaceBetween,
|
|
|
+ verticalAlignment = Alignment.CenterVertically,
|
|
|
+ modifier = Modifier
|
|
|
+ .fillMaxWidth()
|
|
|
+ ) {
|
|
|
BasicText(
|
|
|
- text = playerState.mediaMetadata.artist?.toString() ?: "",
|
|
|
- style = typography.s.semiBold.secondary,
|
|
|
+ text = DateUtils.formatElapsedTime(
|
|
|
+ (scrubbingPosition ?: playerState.currentPosition) / 1000
|
|
|
+ ),
|
|
|
+ style = typography.xxs.semiBold,
|
|
|
maxLines = 1,
|
|
|
overflow = TextOverflow.Ellipsis,
|
|
|
- modifier = Modifier
|
|
|
- .padding(horizontal = 32.dp)
|
|
|
- )
|
|
|
-
|
|
|
- SeekBar(
|
|
|
- value = scrubbingPosition ?: playerState.currentPosition,
|
|
|
- minimumValue = 0,
|
|
|
- maximumValue = playerState.duration,
|
|
|
- onDragStart = {
|
|
|
- scrubbingPosition = it
|
|
|
- },
|
|
|
- onDrag = { delta ->
|
|
|
- scrubbingPosition = if (playerState.duration != C.TIME_UNSET) {
|
|
|
- scrubbingPosition?.plus(delta)?.coerceIn(0, playerState.duration)
|
|
|
- } else {
|
|
|
- null
|
|
|
- }
|
|
|
- },
|
|
|
- onDragEnd = {
|
|
|
- scrubbingPosition?.let(player::seekTo)
|
|
|
- scrubbingPosition = null
|
|
|
- },
|
|
|
- color = colorPalette.text,
|
|
|
- backgroundColor = colorPalette.textDisabled,
|
|
|
- shape = RoundedCornerShape(8.dp),
|
|
|
- modifier = Modifier
|
|
|
- .padding(top = 24.dp, bottom = 12.dp)
|
|
|
- .padding(horizontal = 32.dp)
|
|
|
- .fillMaxWidth()
|
|
|
)
|
|
|
|
|
|
- Row(
|
|
|
- horizontalArrangement = Arrangement.SpaceBetween,
|
|
|
- verticalAlignment = Alignment.CenterVertically,
|
|
|
- modifier = Modifier
|
|
|
- .padding(horizontal = 32.dp)
|
|
|
- .fillMaxWidth()
|
|
|
- .padding(bottom = 16.dp)
|
|
|
- ) {
|
|
|
+ if (playerState.duration != C.TIME_UNSET) {
|
|
|
BasicText(
|
|
|
- text = DateUtils.formatElapsedTime(
|
|
|
- (scrubbingPosition ?: playerState.currentPosition) / 1000
|
|
|
- ),
|
|
|
+ text = DateUtils.formatElapsedTime(playerState.duration / 1000),
|
|
|
style = typography.xxs.semiBold,
|
|
|
maxLines = 1,
|
|
|
overflow = TextOverflow.Ellipsis,
|
|
|
)
|
|
|
-
|
|
|
- if (playerState.duration != C.TIME_UNSET) {
|
|
|
- BasicText(
|
|
|
- text = DateUtils.formatElapsedTime(playerState.duration / 1000),
|
|
|
- style = typography.xxs.semiBold,
|
|
|
- maxLines = 1,
|
|
|
- overflow = TextOverflow.Ellipsis,
|
|
|
- )
|
|
|
- }
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- Row(
|
|
|
- horizontalArrangement = Arrangement.SpaceBetween,
|
|
|
- verticalAlignment = Alignment.CenterVertically,
|
|
|
+
|
|
|
+ Spacer(
|
|
|
+ modifier = Modifier
|
|
|
+ .weight(1f)
|
|
|
+ )
|
|
|
+
|
|
|
+ Row(
|
|
|
+ verticalAlignment = Alignment.CenterVertically,
|
|
|
+ modifier = Modifier
|
|
|
+ .fillMaxWidth()
|
|
|
+ ) {
|
|
|
+ Image(
|
|
|
+ painter = painterResource(R.drawable.heart),
|
|
|
+ contentDescription = null,
|
|
|
+ colorFilter = ColorFilter.tint(
|
|
|
+ song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled
|
|
|
+ ),
|
|
|
modifier = Modifier
|
|
|
- .padding(vertical = 32.dp)
|
|
|
- ) {
|
|
|
- Image(
|
|
|
- painter = painterResource(R.drawable.heart),
|
|
|
- contentDescription = null,
|
|
|
- colorFilter = ColorFilter.tint(
|
|
|
- song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled
|
|
|
- ),
|
|
|
- modifier = Modifier
|
|
|
- .clickable {
|
|
|
- query {
|
|
|
- song?.let { song ->
|
|
|
- Database.update(song.toggleLike())
|
|
|
- } ?: Database.insert(playerState.mediaItem, Song::toggleLike)
|
|
|
- }
|
|
|
+ .clickable {
|
|
|
+ query {
|
|
|
+ song?.let { song ->
|
|
|
+ Database.update(song.toggleLike())
|
|
|
+ } ?: Database.insert(playerState.mediaItem!!, Song::toggleLike)
|
|
|
}
|
|
|
- .padding(horizontal = 16.dp)
|
|
|
- .size(28.dp)
|
|
|
- )
|
|
|
+ }
|
|
|
+ .weight(1f)
|
|
|
+ .size(28.dp)
|
|
|
+ )
|
|
|
|
|
|
- Image(
|
|
|
- painter = painterResource(R.drawable.play_skip_back),
|
|
|
- contentDescription = null,
|
|
|
- colorFilter = ColorFilter.tint(colorPalette.text),
|
|
|
- modifier = Modifier
|
|
|
- .clickable(onClick = player::seekToPrevious)
|
|
|
- .padding(horizontal = 16.dp)
|
|
|
- .size(28.dp)
|
|
|
- )
|
|
|
+ Image(
|
|
|
+ painter = painterResource(R.drawable.play_skip_back),
|
|
|
+ contentDescription = null,
|
|
|
+ colorFilter = ColorFilter.tint(colorPalette.text),
|
|
|
+ modifier = Modifier
|
|
|
+ .clickable(onClick = player::seekToPrevious)
|
|
|
+ .weight(1f)
|
|
|
+ .size(28.dp)
|
|
|
+ )
|
|
|
|
|
|
- val isPaused = playerState.playbackState == Player.STATE_ENDED || !playerState.playWhenReady
|
|
|
+ Spacer(
|
|
|
+ modifier = Modifier
|
|
|
+ .width(8.dp)
|
|
|
+ )
|
|
|
|
|
|
- Box(
|
|
|
- modifier = Modifier
|
|
|
- .padding(horizontal = 8.dp)
|
|
|
- .clickable {
|
|
|
- if (isPaused) {
|
|
|
- if (player.playbackState == Player.STATE_IDLE) {
|
|
|
- player.prepare()
|
|
|
- }
|
|
|
+ val isPaused =
|
|
|
+ playerState.playbackState == Player.STATE_ENDED || !playerState.playWhenReady
|
|
|
|
|
|
- player.play()
|
|
|
- } else {
|
|
|
- player.pause()
|
|
|
+ Box(
|
|
|
+ modifier = Modifier
|
|
|
+ .clickable {
|
|
|
+ if (isPaused) {
|
|
|
+ if (player.playbackState == Player.STATE_IDLE) {
|
|
|
+ player.prepare()
|
|
|
}
|
|
|
- }
|
|
|
- .background(color = colorPalette.text, shape = CircleShape)
|
|
|
- .size(64.dp)
|
|
|
- ) {
|
|
|
- Image(
|
|
|
- painter = painterResource(if (isPaused) R.drawable.play else R.drawable.pause),
|
|
|
- contentDescription = null,
|
|
|
- colorFilter = ColorFilter.tint(colorPalette.background),
|
|
|
- modifier = Modifier
|
|
|
- .align(Alignment.Center)
|
|
|
- .size(28.dp)
|
|
|
- )
|
|
|
- }
|
|
|
|
|
|
- Image(
|
|
|
- painter = painterResource(R.drawable.play_skip_forward),
|
|
|
- contentDescription = null,
|
|
|
- colorFilter = ColorFilter.tint(colorPalette.text),
|
|
|
- modifier = Modifier
|
|
|
- .clickable(onClick = player::seekToNext)
|
|
|
- .padding(horizontal = 16.dp)
|
|
|
- .size(28.dp)
|
|
|
- )
|
|
|
-
|
|
|
- Image(
|
|
|
- painter = painterResource(
|
|
|
- if (playerState.repeatMode == Player.REPEAT_MODE_ONE) {
|
|
|
- R.drawable.repeat_one
|
|
|
+ player.play()
|
|
|
} else {
|
|
|
- R.drawable.repeat
|
|
|
+ player.pause()
|
|
|
}
|
|
|
- ),
|
|
|
+ }
|
|
|
+ .background(color = colorPalette.text, shape = CircleShape)
|
|
|
+ .size(64.dp)
|
|
|
+ ) {
|
|
|
+ Image(
|
|
|
+ painter = painterResource(if (isPaused) R.drawable.play else R.drawable.pause),
|
|
|
contentDescription = null,
|
|
|
- colorFilter = ColorFilter.tint(
|
|
|
- if (playerState.repeatMode == Player.REPEAT_MODE_OFF) {
|
|
|
- colorPalette.textDisabled
|
|
|
- } else {
|
|
|
- colorPalette.text
|
|
|
- }
|
|
|
- ),
|
|
|
+ colorFilter = ColorFilter.tint(colorPalette.background),
|
|
|
modifier = Modifier
|
|
|
- .clickable {
|
|
|
- player.repeatMode
|
|
|
- .plus(2)
|
|
|
- .mod(3)
|
|
|
- .let { repeatMode ->
|
|
|
- player.repeatMode = repeatMode
|
|
|
- preferences.repeatMode = repeatMode
|
|
|
- }
|
|
|
- }
|
|
|
- .padding(horizontal = 16.dp)
|
|
|
+ .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 = player::seekToNext)
|
|
|
+ .weight(1f)
|
|
|
+ .size(28.dp)
|
|
|
+ )
|
|
|
+
|
|
|
+ Image(
|
|
|
+ painter = painterResource(
|
|
|
+ if (playerState.repeatMode == Player.REPEAT_MODE_ONE) {
|
|
|
+ R.drawable.repeat_one
|
|
|
+ } else {
|
|
|
+ R.drawable.repeat
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ contentDescription = null,
|
|
|
+ colorFilter = ColorFilter.tint(
|
|
|
+ if (playerState.repeatMode == Player.REPEAT_MODE_OFF) {
|
|
|
+ colorPalette.textDisabled
|
|
|
+ } else {
|
|
|
+ colorPalette.text
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ modifier = Modifier
|
|
|
+ .clickable {
|
|
|
+ player.repeatMode
|
|
|
+ .plus(2)
|
|
|
+ .mod(3)
|
|
|
+ .let { repeatMode ->
|
|
|
+ player.repeatMode = repeatMode
|
|
|
+ preferences.repeatMode = repeatMode
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .weight(1f)
|
|
|
+ .size(28.dp)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
- PlayerBottomSheet(
|
|
|
- playerState = playerState,
|
|
|
- layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound * 0.9f),
|
|
|
- onGlobalRouteEmitted = layoutState.collapse,
|
|
|
- padding = layoutState.upperBound * 0.1f,
|
|
|
- song = song,
|
|
|
+ Spacer(
|
|
|
modifier = Modifier
|
|
|
- .align(Alignment.BottomCenter)
|
|
|
+ .weight(1f)
|
|
|
)
|
|
|
}
|
|
|
-}
|
|
|
-
|
|
|
+}
|