Make Thumbnail composable smart recompose

This commit is contained in:
vfsfitvnm
2022-07-21 12:08:18 +02:00
parent 5ed77b8c32
commit de46f90793
2 changed files with 274 additions and 203 deletions

View File

@@ -36,8 +36,7 @@ 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.media3.common.C import androidx.media3.common.*
import androidx.media3.common.Player
import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheSpan import androidx.media3.datasource.cache.CacheSpan
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -45,13 +44,15 @@ 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.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Format
import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.* import it.vfsfitvnm.vimusic.ui.components.*
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.LoadingOrError
import it.vfsfitvnm.vimusic.ui.styling.* import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.utils.* import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -189,10 +190,7 @@ fun PlayerView(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
) { ) {
Thumbnail( Thumbnail()
playerState = playerState,
modifier = Modifier
)
} }
Controls( Controls(
@@ -219,10 +217,7 @@ fun PlayerView(
.weight(1.25f) .weight(1.25f)
.padding(horizontal = 32.dp, vertical = 8.dp) .padding(horizontal = 32.dp, vertical = 8.dp)
) { ) {
Thumbnail( Thumbnail()
playerState = playerState,
modifier = Modifier
)
} }
Controls( Controls(
@@ -300,7 +295,6 @@ fun PlayerView(
) )
} }
PlayerBottomSheet( PlayerBottomSheet(
playerState = playerState, playerState = playerState,
layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound * 0.9f), layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound * 0.9f),
@@ -316,27 +310,26 @@ fun PlayerView(
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
private fun Thumbnail( private fun Thumbnail(
playerState: PlayerState,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val (_, typography) = LocalAppearance.current
val context = LocalContext.current
val binder = LocalPlayerServiceBinder.current val binder = LocalPlayerServiceBinder.current
val player = binder?.player ?: return val player = binder?.player ?: return
playerState.mediaItem ?: return
val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let { val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let {
it to (it - 64.dp).px it to (it - 64.dp).px
} }
val mediaItemIndex by rememberMediaItemIndex(player)
val error by rememberError(player)
var isShowingStatsForNerds by rememberSaveable { var isShowingStatsForNerds by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
if (playerState.error == null) { if (error == null) {
AnimatedContent( AnimatedContent(
targetState = playerState.mediaItemIndex, targetState = mediaItemIndex,
transitionSpec = { transitionSpec = {
val slideDirection = val slideDirection =
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
@@ -348,11 +341,9 @@ private fun Thumbnail(
}, },
modifier = modifier modifier = modifier
.aspectRatio(1f) .aspectRatio(1f)
) { ) { currentMediaItemIndex ->
val artworkUri = remember(it) { val mediaItem = remember(currentMediaItemIndex) {
player.getMediaItemAt(it).mediaMetadata.artworkUri.thumbnail( player.getMediaItemAt(currentMediaItemIndex)
thumbnailSizePx
)
} }
Box( Box(
@@ -361,7 +352,7 @@ private fun Thumbnail(
.size(thumbnailSizeDp) .size(thumbnailSizeDp)
) { ) {
AsyncImage( AsyncImage(
model = artworkUri, model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
@@ -375,169 +366,13 @@ private fun Thumbnail(
.fillMaxSize() .fillMaxSize()
) )
AnimatedVisibility( StatsForNerds(
visible = isShowingStatsForNerds, mediaId = mediaItem.mediaId,
enter = fadeIn(), isDisplayed = isShowingStatsForNerds,
exit = fadeOut(), onDismiss = {
) { isShowingStatsForNerds = false
val key = playerState.mediaItem.mediaId
var cachedBytes by remember(key) {
mutableStateOf(binder.cache.getCachedBytes(key, 0, -1))
} }
)
val format by remember(key) {
Database.format(key)
}.collectAsState(initial = null, context = Dispatchers.IO)
DisposableEffect(key) {
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(key, listener)
onDispose {
binder.cache.removeListener(key, listener)
}
}
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 = "Bitrate",
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)
)
}
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 = format?.loudnessDb?.let { loudnessDb ->
"%.2f dB".format(loudnessDb)
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = format?.bitrate?.let { bitrate ->
"${bitrate / 1000} kbps"
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = format?.contentLength?.let { contentLength ->
Formatter.formatShortFileSize(
context,
contentLength
)
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = buildString {
append(Formatter.formatShortFileSize(context, cachedBytes))
format?.contentLength?.let { contentLength ->
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
}
},
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
}
}
if (format != null && format?.itag == null) {
BasicText(
text = "FETCH MISSING DATA",
style = typography.xxs.semiBold.color(BlackColorPalette.text),
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = {
query {
runBlocking(Dispatchers.IO) {
YouTube.player(key)?.map { response ->
response.streamingData?.adaptiveFormats?.findLast { format ->
format.itag == 251 || format.itag == 140
}?.let { format ->
Format(
songId = key,
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(all = 16.dp)
.align(Alignment.End)
)
}
}
}
} }
} }
} else { } else {
@@ -549,7 +384,7 @@ private fun Thumbnail(
.size(thumbnailSizeDp) .size(thumbnailSizeDp)
) { ) {
LoadingOrError( LoadingOrError(
errorMessage = playerState.error.javaClass.canonicalName, errorMessage = error?.javaClass?.canonicalName,
onRetry = { onRetry = {
player.playWhenReady = true player.playWhenReady = true
player.prepare() player.prepare()
@@ -559,6 +394,188 @@ private fun Thumbnail(
} }
} }
@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)
}
}
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onDismiss()
}
)
}
.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 = "Bitrate",
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)
)
}
Column {
BasicText(
text = mediaId,
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = "${volume.times(100).roundToInt()}%",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = format?.loudnessDb?.let { loudnessDb ->
"%.2f dB".format(loudnessDb)
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = format?.bitrate?.let { bitrate ->
"${bitrate / 1000} kbps"
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = format?.contentLength?.let { contentLength ->
Formatter.formatShortFileSize(
context,
contentLength
)
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = buildString {
append(Formatter.formatShortFileSize(context, cachedBytes))
format?.contentLength?.let { contentLength ->
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
}
},
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
}
}
if (format != null && format?.itag == null) {
BasicText(
text = "FETCH MISSING DATA",
style = typography.xxs.semiBold.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(all = 16.dp)
.align(Alignment.End)
)
}
}
}
}
@Composable @Composable
private fun Controls( private fun Controls(
playerState: PlayerState, playerState: PlayerState,

View File

@@ -17,9 +17,7 @@ data class PlayerState(
val mediaMetadata: MediaMetadata, val mediaMetadata: MediaMetadata,
val playWhenReady: Boolean, val playWhenReady: Boolean,
val repeatMode: Int, val repeatMode: Int,
val error: PlaybackException?,
val mediaItems: List<MediaItem>, val mediaItems: List<MediaItem>,
val volume: Float
) { ) {
constructor(player: Player) : this( constructor(player: Player) : this(
currentPosition = player.currentPosition, currentPosition = player.currentPosition,
@@ -30,9 +28,7 @@ data class PlayerState(
mediaMetadata = player.mediaMetadata, mediaMetadata = player.mediaMetadata,
playWhenReady = player.playWhenReady, playWhenReady = player.playWhenReady,
repeatMode = player.repeatMode, repeatMode = player.repeatMode,
error = player.playerError, mediaItems = player.currentTimeline.mediaItems
mediaItems = player.currentTimeline.mediaItems,
volume = player.volume
) )
val progress: Float val progress: Float
@@ -56,12 +52,8 @@ fun rememberPlayerState(
val handler = Handler(Looper.getMainLooper()) val handler = Handler(Looper.getMainLooper())
val listener = object : Player.Listener, Runnable { val listener = object : Player.Listener, Runnable {
override fun onVolumeChanged(volume: Float) {
playerState = playerState?.copy(volume = volume)
}
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
playerState = playerState?.copy(playbackState = playbackState, error = player.playerError) playerState = playerState?.copy(playbackState = playbackState)
if (playbackState == Player.STATE_READY) { if (playbackState == Player.STATE_READY) {
isSeeking = false isSeeking = false
@@ -88,10 +80,6 @@ fun rememberPlayerState(
playerState = playerState?.copy(repeatMode = repeatMode) playerState = playerState?.copy(repeatMode = repeatMode)
} }
override fun onPlayerError(playbackException: PlaybackException) {
playerState = playerState?.copy(error = playbackException)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) { override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerState = playerState?.copy( playerState = playerState?.copy(
mediaItems = timeline.mediaItems, mediaItems = timeline.mediaItems,
@@ -136,3 +124,69 @@ fun rememberPlayerState(
return playerState return playerState
} }
context(DisposableEffectScope)
fun Player.listener(listener: Player.Listener): DisposableEffectResult {
addListener(listener)
return onDispose {
removeListener(listener)
}
}
@Composable
fun rememberMediaItemIndex(player: Player): State<Int> {
val mediaItemIndexState = remember(player) {
mutableStateOf(player.currentMediaItemIndex)
}
DisposableEffect(player) {
player.listener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
mediaItemIndexState.value = player.currentMediaItemIndex
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
mediaItemIndexState.value = player.currentMediaItemIndex
}
})
}
return mediaItemIndexState
}
@Composable
fun rememberVolume(player: Player): State<Float> {
val volumeState = remember(player) {
mutableStateOf(player.volume)
}
DisposableEffect(player) {
player.listener(object : Player.Listener {
override fun onVolumeChanged(volume: Float) {
volumeState.value = volume
}
})
}
return volumeState
}
@Composable
fun rememberError(player: Player): State<PlaybackException?> {
val errorState = remember(player) {
mutableStateOf(player.playerError)
}
DisposableEffect(player) {
player.listener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
errorState.value = player.playerError
}
override fun onPlayerError(playbackException: PlaybackException) {
errorState.value = playbackException
}
})
}
return errorState
}