From de46f907939d0a1ca4141d72ba71edda13c233f0 Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Thu, 21 Jul 2022 12:08:18 +0200 Subject: [PATCH] Make Thumbnail composable smart recompose --- .../vfsfitvnm/vimusic/ui/views/PlayerView.kt | 395 +++++++++--------- .../it/vfsfitvnm/vimusic/utils/PlayerState.kt | 82 +++- 2 files changed, 274 insertions(+), 203 deletions(-) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt index 28a4761..ac05f07 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt @@ -36,8 +36,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.media3.common.C -import androidx.media3.common.Player +import androidx.media3.common.* import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheSpan import coil.compose.AsyncImage @@ -45,13 +44,15 @@ import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.models.Format import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.* 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.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.youtubemusic.YouTube import kotlinx.coroutines.Dispatchers @@ -189,10 +190,7 @@ fun PlayerView( .padding(horizontal = 16.dp) .padding(bottom = 16.dp) ) { - Thumbnail( - playerState = playerState, - modifier = Modifier - ) + Thumbnail() } Controls( @@ -219,10 +217,7 @@ fun PlayerView( .weight(1.25f) .padding(horizontal = 32.dp, vertical = 8.dp) ) { - Thumbnail( - playerState = playerState, - modifier = Modifier - ) + Thumbnail() } Controls( @@ -300,7 +295,6 @@ fun PlayerView( ) } - PlayerBottomSheet( playerState = playerState, layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound * 0.9f), @@ -316,27 +310,26 @@ fun PlayerView( @ExperimentalAnimationApi @Composable private fun Thumbnail( - playerState: PlayerState, modifier: Modifier = Modifier ) { - val (_, typography) = LocalAppearance.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 } + val mediaItemIndex by rememberMediaItemIndex(player) + + val error by rememberError(player) + var isShowingStatsForNerds by rememberSaveable { mutableStateOf(false) } - if (playerState.error == null) { + if (error == null) { AnimatedContent( - targetState = playerState.mediaItemIndex, + targetState = mediaItemIndex, transitionSpec = { val slideDirection = if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right @@ -348,11 +341,9 @@ private fun Thumbnail( }, modifier = modifier .aspectRatio(1f) - ) { - val artworkUri = remember(it) { - player.getMediaItemAt(it).mediaMetadata.artworkUri.thumbnail( - thumbnailSizePx - ) + ) { currentMediaItemIndex -> + val mediaItem = remember(currentMediaItemIndex) { + player.getMediaItemAt(currentMediaItemIndex) } Box( @@ -361,7 +352,7 @@ private fun Thumbnail( .size(thumbnailSizeDp) ) { AsyncImage( - model = artworkUri, + model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -375,169 +366,13 @@ private fun Thumbnail( .fillMaxSize() ) - AnimatedVisibility( - visible = isShowingStatsForNerds, - enter = fadeIn(), - exit = fadeOut(), - ) { - val key = playerState.mediaItem.mediaId - - var cachedBytes by remember(key) { - mutableStateOf(binder.cache.getCachedBytes(key, 0, -1)) + StatsForNerds( + mediaId = mediaItem.mediaId, + isDisplayed = isShowingStatsForNerds, + onDismiss = { + isShowingStatsForNerds = false } - - 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 { @@ -549,7 +384,7 @@ private fun Thumbnail( .size(thumbnailSizeDp) ) { LoadingOrError( - errorMessage = playerState.error.javaClass.canonicalName, + errorMessage = error?.javaClass?.canonicalName, onRetry = { player.playWhenReady = true 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 private fun Controls( playerState: PlayerState, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt index 6b2b25f..b4339f0 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt @@ -17,9 +17,7 @@ data class PlayerState( val mediaMetadata: MediaMetadata, val playWhenReady: Boolean, val repeatMode: Int, - val error: PlaybackException?, val mediaItems: List, - val volume: Float ) { constructor(player: Player) : this( currentPosition = player.currentPosition, @@ -30,9 +28,7 @@ data class PlayerState( mediaMetadata = player.mediaMetadata, playWhenReady = player.playWhenReady, repeatMode = player.repeatMode, - error = player.playerError, - mediaItems = player.currentTimeline.mediaItems, - volume = player.volume + mediaItems = player.currentTimeline.mediaItems ) val progress: Float @@ -56,12 +52,8 @@ fun rememberPlayerState( val handler = Handler(Looper.getMainLooper()) val listener = object : Player.Listener, Runnable { - override fun onVolumeChanged(volume: Float) { - playerState = playerState?.copy(volume = volume) - } - override fun onPlaybackStateChanged(playbackState: Int) { - playerState = playerState?.copy(playbackState = playbackState, error = player.playerError) + playerState = playerState?.copy(playbackState = playbackState) if (playbackState == Player.STATE_READY) { isSeeking = false @@ -88,10 +80,6 @@ fun rememberPlayerState( playerState = playerState?.copy(repeatMode = repeatMode) } - override fun onPlayerError(playbackException: PlaybackException) { - playerState = playerState?.copy(error = playbackException) - } - override fun onTimelineChanged(timeline: Timeline, reason: Int) { playerState = playerState?.copy( mediaItems = timeline.mediaItems, @@ -136,3 +124,69 @@ fun rememberPlayerState( return playerState } + +context(DisposableEffectScope) +fun Player.listener(listener: Player.Listener): DisposableEffectResult { + addListener(listener) + return onDispose { + removeListener(listener) + } +} + +@Composable +fun rememberMediaItemIndex(player: Player): State { + 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 { + 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 { + 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 +} \ No newline at end of file