From a5a0e2378b3de0b76b549e313a0146d5e9615bd7 Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sat, 15 Oct 2022 22:12:44 +0200 Subject: [PATCH] Rewrite NowPlayingView in compose --- .../jellyfin/androidtv/ui/NowPlayingView.kt | 223 +++++++++++------- .../ui/browsing/BrowseGridFragment.java | 5 - .../androidtv/ui/composable/mediaItem.kt | 23 +- app/src/main/res/layout/view_now_playing.xml | 53 ----- 4 files changed, 151 insertions(+), 153 deletions(-) delete mode 100644 app/src/main/res/layout/view_now_playing.xml diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/NowPlayingView.kt b/app/src/main/java/org/jellyfin/androidtv/ui/NowPlayingView.kt index f43542d765..33d2a083cf 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/NowPlayingView.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/NowPlayingView.kt @@ -2,103 +2,148 @@ package org.jellyfin.androidtv.ui import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.FrameLayout +import android.widget.ImageView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.core.view.setPadding +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ProvideTextStyle +import androidx.tv.material3.Surface +import androidx.tv.material3.Text import org.jellyfin.androidtv.R -import org.jellyfin.androidtv.databinding.ViewNowPlayingBinding +import org.jellyfin.androidtv.ui.composable.AsyncImage +import org.jellyfin.androidtv.ui.composable.rememberMediaItem import org.jellyfin.androidtv.ui.navigation.Destinations import org.jellyfin.androidtv.ui.navigation.NavigationRepository -import org.jellyfin.androidtv.ui.playback.AudioEventListener import org.jellyfin.androidtv.ui.playback.MediaManager -import org.jellyfin.androidtv.ui.playback.PlaybackController import org.jellyfin.androidtv.util.ImageHelper -import org.jellyfin.androidtv.util.TimeUtils -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +import org.jellyfin.sdk.model.api.ImageType +import org.koin.compose.koinInject + +@Composable +fun NowPlayingComposable() { + val mediaManager = koinInject() + val navigationRepository = koinInject() + val imageHelper = koinInject() + + val (item, progress) = rememberMediaItem(mediaManager) + + AnimatedVisibility( + visible = item != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Surface( + onClick = { navigationRepository.navigate(Destinations.nowPlaying) }, + colors = ClickableSurfaceDefaults.colors( + containerColor = colorResource(id = R.color.button_default_normal_background), + focusedContainerColor = colorResource(id = R.color.button_default_highlight_background), + contentColor = colorResource(id = R.color.button_default_normal_text), + focusedContentColor = colorResource(id = R.color.button_default_highlight_text), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + shape = ClickableSurfaceDefaults.shape( + shape = RoundedCornerShape(4.dp), + ), + modifier = Modifier + .widthIn(0.dp, 250.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(1.dp) + .drawWithContent { + // Background + drawRect(Color.White, alpha = 0.4f) + // Foreground + drawRect(Color.White, size = size.copy(width = size.width * progress)) + } + ) + + ProvideTextStyle( + value = TextStyle.Default.copy( + fontSize = 12.sp, + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(5.dp) + ) { + val primaryImageTag = item?.imageTags?.get(ImageType.PRIMARY) + val (imageItemId, imageTag) = when { + primaryImageTag != null -> item.id to primaryImageTag + (item?.albumId != null && item.albumPrimaryImageTag != null) -> item.albumId to item.albumPrimaryImageTag + else -> null to null + } + val imageUrl = when { + imageItemId != null && imageTag != null -> imageHelper.getImageUrl( + itemId = imageItemId, + imageType = ImageType.PRIMARY, + imageTag = imageTag + ) + + else -> null + } + val imageBlurHash = imageTag?.let { tag -> + item?.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) + } + + AsyncImage( + url = imageUrl, + blurHash = imageBlurHash, + placeholder = ContextCompat.getDrawable(LocalContext.current, R.drawable.ic_album), + aspectRatio = item?.primaryImageAspectRatio ?: 1.0, + modifier = Modifier + .size(35.dp) + .clip(RoundedCornerShape(4.dp)), + scaleType = ImageView.ScaleType.CENTER_CROP, + ) + + Column( + verticalArrangement = Arrangement.SpaceAround, + ) { + // Name + Text(text = item?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = item?.albumArtist.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } +} class NowPlayingView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = R.style.Button_Default, -) : FrameLayout(context, attrs, defStyleAttr, defStyleRes), KoinComponent { - val binding = ViewNowPlayingBinding.inflate(LayoutInflater.from(context), this, true) - - private val mediaManager by inject() - private val navigationRepository by inject() - private val imageHelper by inject() - private var currentDuration: String = "" - - init { - setPadding(0) - - if (!isInEditMode) setOnClickListener { - navigationRepository.navigate(Destinations.nowPlaying) - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - - if (!isInEditMode) { - // hook our events - mediaManager.addAudioEventListener(audioEventListener) - - if (mediaManager.hasAudioQueueItems()) { - isVisible = true - setInfo(mediaManager.currentAudioItem!!) - setStatus(mediaManager.currentAudioPosition) - } else isVisible = false - } - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - - if (!isInEditMode) mediaManager.removeAudioEventListener(audioEventListener) - } - - private fun setInfo(item: org.jellyfin.sdk.model.api.BaseItemDto) { - val placeholder = ContextCompat.getDrawable(context, R.drawable.ic_album) - val blurHash = item.imageBlurHashes?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY)?.get(item.imageTags?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY)) - binding.npIcon.load(imageHelper.getPrimaryImageUrl(item), blurHash, placeholder, item.primaryImageAspectRatio ?: 1.0) - - currentDuration = TimeUtils.formatMillis(if (item.runTimeTicks != null) item.runTimeTicks!! / 10_000 else 0) - binding.npDesc.text = if (item.albumArtist != null) item.albumArtist else item.name - } - - private fun setStatus(pos: Long) { - binding.npStatus.text = resources.getString(R.string.lbl_status, TimeUtils.formatMillis(pos), currentDuration) - } - - fun showDescription(show: Boolean) { - binding.npDesc.isVisible = show - } - - private var audioEventListener: AudioEventListener = object : AudioEventListener { - override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: org.jellyfin.sdk.model.api.BaseItemDto?) { - when { - currentItem == null -> Unit - newState == PlaybackController.PlaybackState.PLAYING -> setInfo(currentItem) - newState == PlaybackController.PlaybackState.IDLE && isShown -> setStatus(mediaManager.currentAudioPosition) - } - } - - override fun onProgress(pos: Long) { - if (isShown) setStatus(pos) - } - - override fun onQueueStatusChanged(hasQueue: Boolean) { - isVisible = hasQueue - - if (hasQueue) { - // may have just added one so update display - setInfo(mediaManager.currentAudioItem!!) - setStatus(mediaManager.currentAudioPosition) - } - } - } + defStyle: Int = 0 +) : AbstractComposeView(context, attrs, defStyle) { + @Composable + override fun Content() = NowPlayingComposable() } diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/browsing/BrowseGridFragment.java b/app/src/main/java/org/jellyfin/androidtv/ui/browsing/BrowseGridFragment.java index 2d6125cce8..69f53d7b36 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/browsing/BrowseGridFragment.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/browsing/BrowseGridFragment.java @@ -185,12 +185,7 @@ public void onCreate(Bundle savedInstanceState) { @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = HorizontalGridBrowseBinding.inflate(inflater, container, false); - - // Hide the description because we don't have room for it - binding.npBug.showDescription(false); - return binding.getRoot(); } diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/composable/mediaItem.kt b/app/src/main/java/org/jellyfin/androidtv/ui/composable/mediaItem.kt index 474fabe639..c1bce1349a 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/composable/mediaItem.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/composable/mediaItem.kt @@ -2,36 +2,47 @@ package org.jellyfin.androidtv.ui.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import org.jellyfin.androidtv.ui.playback.AudioEventListener import org.jellyfin.androidtv.ui.playback.MediaManager import org.jellyfin.androidtv.ui.playback.PlaybackController import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.extensions.ticks import org.koin.compose.koinInject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Composable fun rememberMediaItem( mediaManager: MediaManager = koinInject(), -): State { - val item = remember { mutableStateOf(mediaManager.currentAudioItem) } +): Pair { + var progress by remember { mutableFloatStateOf(0f) } + var item by remember { mutableStateOf(mediaManager.currentAudioItem) } DisposableEffect(mediaManager) { val listener = object : AudioEventListener { override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: BaseItemDto?) { - item.value = currentItem + item = currentItem } override fun onQueueStatusChanged(hasQueue: Boolean) { super.onQueueStatusChanged(hasQueue) - item.value = mediaManager.currentAudioItem + item = mediaManager.currentAudioItem + } + + override fun onProgress(pos: Long) { + val duration = item?.runTimeTicks?.ticks ?: Duration.ZERO + progress = (pos.milliseconds / duration).toFloat() } } mediaManager.addAudioEventListener(listener) onDispose { mediaManager.removeAudioEventListener(listener) } } - return item + return item to progress } diff --git a/app/src/main/res/layout/view_now_playing.xml b/app/src/main/res/layout/view_now_playing.xml deleted file mode 100644 index 03a9a0e6cf..0000000000 --- a/app/src/main/res/layout/view_now_playing.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - -