Make Thumbnail composable smart recompose
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -17,9 +17,7 @@ data class PlayerState(
|
||||
val mediaMetadata: MediaMetadata,
|
||||
val playWhenReady: Boolean,
|
||||
val repeatMode: Int,
|
||||
val error: PlaybackException?,
|
||||
val mediaItems: List<MediaItem>,
|
||||
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<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
|
||||
}
|
||||
Reference in New Issue
Block a user