diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt index 841c5f2062..c7b9f3aea3 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt @@ -15,18 +15,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import org.jellyfin.androidtv.integration.dream.model.DreamContent import org.jellyfin.androidtv.preference.UserPreferences import org.jellyfin.playback.core.PlaybackManager -import org.jellyfin.playback.core.queue.QueueEntry import org.jellyfin.playback.core.queue.queue -import org.jellyfin.playback.jellyfin.lyricsFlow -import org.jellyfin.playback.jellyfin.queue.baseItemFlow +import org.jellyfin.playback.jellyfin.queue.baseItem import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.extensions.imageApi @@ -46,25 +43,14 @@ class DreamViewModel( playbackManager: PlaybackManager, private val userPreferences: UserPreferences, ) : ViewModel() { - private val QueueEntry.nowPlayingFlow - get() = combine(baseItemFlow, lyricsFlow) { baseItem, lyrics -> - baseItem?.let { - DreamContent.NowPlaying( - item = baseItem, - lyrics = lyrics, - ) - } - } - @OptIn(ExperimentalCoroutinesApi::class) private val _mediaContent = playbackManager.queue.entry - .flatMapLatest { entry -> entry?.nowPlayingFlow ?: emptyFlow() } - .distinctUntilChanged() - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - null, - ) + .map { entry -> + entry?.baseItem?.let { baseItem -> + DreamContent.NowPlaying(entry, baseItem) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) private val _libraryContent = flow { // Load first library item after 2 seconds @@ -108,7 +94,7 @@ class DreamViewModel( hasParentalRating = if (requireParentalRating) true else null, ) - val item = response.items?.firstOrNull { item -> + val item = response.items.firstOrNull { item -> !item.backdropImageTags.isNullOrEmpty() } ?: return null diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentNowPlaying.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentNowPlaying.kt index 1621a0708a..cd403a9926 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentNowPlaying.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentNowPlaying.kt @@ -14,12 +14,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -31,22 +27,22 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text -import kotlinx.coroutines.delay import org.jellyfin.androidtv.integration.dream.model.DreamContent import org.jellyfin.androidtv.ui.composable.AsyncImage import org.jellyfin.androidtv.ui.composable.LyricsDtoBox import org.jellyfin.androidtv.ui.composable.blurHashPainter import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges import org.jellyfin.androidtv.ui.composable.modifier.overscan +import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress import org.jellyfin.playback.core.PlaybackManager import org.jellyfin.playback.core.model.PlayState +import org.jellyfin.playback.jellyfin.lyrics +import org.jellyfin.playback.jellyfin.lyricsFlow import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.ImageFormat import org.jellyfin.sdk.model.api.ImageType import org.koin.compose.koinInject -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds @Composable fun DreamContentNowPlaying( @@ -56,21 +52,8 @@ fun DreamContentNowPlaying( ) { val api = koinInject() val playbackManager = koinInject() - - // Track playback position & duration - var playbackPosition by remember { mutableStateOf(Duration.ZERO) } - var playbackDuration by remember { mutableStateOf(Duration.ZERO) } - val playState by remember { playbackManager.state.playState }.collectAsState() - - LaunchedEffect(playState) { - while (true) { - val positionInfo = playbackManager.state.positionInfo - playbackPosition = positionInfo.active - playbackDuration = positionInfo.duration - - delay(1.seconds) - } - } + val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value + val progress = rememberPlayerProgress(playbackManager) val primaryImageTag = content.item.imageTags?.get(ImageType.PRIMARY) val (imageItemId, imageTag) = when { @@ -96,11 +79,12 @@ fun DreamContentNowPlaying( } // Lyrics overlay (on top of background) - if (content.lyrics != null) { + if (lyrics != null) { + val playState by playbackManager.state.playState.collectAsState() LyricsDtoBox( - lyricDto = content.lyrics, - currentTimestamp = playbackPosition, - duration = playbackDuration, + lyricDto = lyrics, + currentTimestamp = playbackManager.state.positionInfo.active, + duration = playbackManager.state.positionInfo.duration, paused = playState != PlayState.PLAYING, fontSize = 22.sp, color = Color.White, @@ -177,7 +161,7 @@ fun DreamContentNowPlaying( // Foreground drawRect( Color.White, - size = size.copy(width = size.width * (playbackPosition.inWholeMilliseconds.toFloat() / playbackDuration.inWholeMilliseconds.toFloat())) + size = size.copy(width = progress * size.width) ) } ) diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/model/DreamContent.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/model/DreamContent.kt index ad7e4c6b2b..ea8a5a0c09 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/dream/model/DreamContent.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/model/DreamContent.kt @@ -1,11 +1,11 @@ package org.jellyfin.androidtv.integration.dream.model import android.graphics.Bitmap +import org.jellyfin.playback.core.queue.QueueEntry import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.LyricDto sealed interface DreamContent { data object Logo : DreamContent data class LibraryShowcase(val item: BaseItemDto, val backdrop: Bitmap, val logo: Bitmap?) : DreamContent - data class NowPlaying(val item: BaseItemDto, val lyrics: LyricDto?) : DreamContent + data class NowPlaying(val entry: QueueEntry, val item: BaseItemDto) : DreamContent } 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 33d2a083cf..2bf183458f 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/NowPlayingView.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/NowPlayingView.kt @@ -17,6 +17,8 @@ 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.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -36,21 +38,26 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.jellyfin.androidtv.R import org.jellyfin.androidtv.ui.composable.AsyncImage -import org.jellyfin.androidtv.ui.composable.rememberMediaItem +import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress +import org.jellyfin.androidtv.ui.composable.rememberQueueEntry import org.jellyfin.androidtv.ui.navigation.Destinations import org.jellyfin.androidtv.ui.navigation.NavigationRepository -import org.jellyfin.androidtv.ui.playback.MediaManager import org.jellyfin.androidtv.util.ImageHelper +import org.jellyfin.playback.core.PlaybackManager +import org.jellyfin.playback.jellyfin.queue.baseItem +import org.jellyfin.playback.jellyfin.queue.baseItemFlow import org.jellyfin.sdk.model.api.ImageType import org.koin.compose.koinInject @Composable fun NowPlayingComposable() { - val mediaManager = koinInject() + val playbackManager = koinInject() val navigationRepository = koinInject() val imageHelper = koinInject() - val (item, progress) = rememberMediaItem(mediaManager) + val entry by rememberQueueEntry(playbackManager) + val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value + val progress = rememberPlayerProgress(playbackManager) AnimatedVisibility( visible = item != null, @@ -81,7 +88,7 @@ fun NowPlayingComposable() { // Background drawRect(Color.White, alpha = 0.4f) // Foreground - drawRect(Color.White, size = size.copy(width = size.width * progress)) + drawRect(Color.White, size = size.copy(width = progress * size.width)) } ) diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/composable/LyricsBox.kt b/app/src/main/java/org/jellyfin/androidtv/ui/composable/LyricsBox.kt index 4af6f1c001..28651c3d87 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/composable/LyricsBox.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/composable/LyricsBox.kt @@ -2,7 +2,6 @@ package org.jellyfin.androidtv.ui.composable import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box @@ -164,32 +163,12 @@ fun LyricsBox( color: Color = LocalTextStyle.current.color, ) = Box(modifier) { var totalHeight by remember { mutableFloatStateOf(0f) } - val activeLineOffsetAnimation = remember { Animatable(0f) } - - LaunchedEffect(paused, duration, totalHeight) { - if (duration == Duration.ZERO) { - activeLineOffsetAnimation.snapTo(0f) - } else { - val progress = (currentTimestamp.inWholeMilliseconds.toFloat() / duration.inWholeMilliseconds.toFloat()).coerceIn(0f, 1f) - - activeLineOffsetAnimation.snapTo(-(progress * totalHeight)) - } - - if (!paused) { - activeLineOffsetAnimation.animateTo( - targetValue = -totalHeight, - animationSpec = tween( - durationMillis = (duration - currentTimestamp).inWholeMilliseconds.toInt(), - easing = LinearEasing, - ) - ) - } - } + val progress = rememberPlayerProgress(!paused, currentTimestamp, duration) LyricsBoxContent( items = lines, modifier = Modifier.graphicsLayer { - translationY = activeLineOffsetAnimation.value + translationY = -(progress * totalHeight) }, onMeasured = { measurements -> totalHeight = measurements.size.height } ) { line, index -> 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 deleted file mode 100644 index c1bce1349a..0000000000 --- a/app/src/main/java/org/jellyfin/androidtv/ui/composable/mediaItem.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.jellyfin.androidtv.ui.composable - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -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(), -): 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 = currentItem - } - - override fun onQueueStatusChanged(hasQueue: Boolean) { - super.onQueueStatusChanged(hasQueue) - - 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 to progress -} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/composable/playback.kt b/app/src/main/java/org/jellyfin/androidtv/ui/composable/playback.kt new file mode 100644 index 0000000000..df814edbc3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/composable/playback.kt @@ -0,0 +1,67 @@ +package org.jellyfin.androidtv.ui.composable + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import org.jellyfin.playback.core.PlaybackManager +import org.jellyfin.playback.core.model.PlayState +import org.jellyfin.playback.core.queue.queue +import org.koin.compose.koinInject +import kotlin.math.roundToInt +import kotlin.time.Duration + +@Composable +fun rememberQueueEntry( + playbackManager: PlaybackManager = koinInject(), +) = remember(playbackManager) { + playbackManager.queue.entry +}.collectAsState() + +@Composable +fun rememberPlayerProgress( + playbackManager: PlaybackManager = koinInject(), +): Float { + val playState by playbackManager.state.playState.collectAsState() + val active = playbackManager.state.positionInfo.active + val duration = playbackManager.state.positionInfo.duration + + return rememberPlayerProgress( + playing = playState == PlayState.PLAYING, + active = active, + duration = duration, + ) +} + +@Composable +fun rememberPlayerProgress( + playing: Boolean, + active: Duration, + duration: Duration, +): Float { + val animatable = remember { Animatable(0f) } + + LaunchedEffect(playing, duration) { + val activeMs = active.inWholeMilliseconds.toFloat() + val durationMs = duration.inWholeMilliseconds.toFloat() + + if (active == Duration.ZERO) animatable.snapTo(0f) + else animatable.snapTo((activeMs / durationMs).coerceIn(0f, 1f)) + + if (playing) { + animatable.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = (durationMs - activeMs).roundToInt(), + easing = LinearEasing, + ) + ) + } + } + + return animatable.value +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragmentHelper.kt b/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragmentHelper.kt index 946c5e86e9..22636ffd7d 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragmentHelper.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragmentHelper.kt @@ -6,27 +6,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest import org.jellyfin.androidtv.ui.AsyncImageView import org.jellyfin.androidtv.ui.composable.LyricsDtoBox import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges +import org.jellyfin.androidtv.ui.composable.rememberQueueEntry import org.jellyfin.playback.core.PlaybackManager import org.jellyfin.playback.core.model.PlayState -import org.jellyfin.playback.core.queue.queue +import org.jellyfin.playback.jellyfin.lyrics import org.jellyfin.playback.jellyfin.lyricsFlow -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds fun initializeLyricsView( coverView: AsyncImageView, @@ -34,12 +27,8 @@ fun initializeLyricsView( playbackManager: PlaybackManager, ) { lyricsView.setContent { - val lyrics by remember { - @OptIn(ExperimentalCoroutinesApi::class) - playbackManager.queue.entry.flatMapLatest { entry -> - entry?.lyricsFlow ?: emptyFlow() - } - }.collectAsState(null) + val entry by rememberQueueEntry(playbackManager) + val lyrics = entry?.run { lyricsFlow.collectAsState(lyrics) }?.value // Animate cover view alpha val coverViewAlpha by animateFloatAsState( @@ -48,27 +37,13 @@ fun initializeLyricsView( ) LaunchedEffect(coverViewAlpha) { coverView.alpha = coverViewAlpha } - // Track playback position & duration - var playbackPosition by remember { mutableStateOf(Duration.ZERO) } - var playbackDuration by remember { mutableStateOf(Duration.ZERO) } - val playState by remember { playbackManager.state.playState }.collectAsState() - - LaunchedEffect(lyrics, playState) { - while (true) { - val positionInfo = playbackManager.state.positionInfo - playbackPosition = positionInfo.active - playbackDuration = positionInfo.duration - - delay(1.seconds) - } - } - // Display lyrics overlay if (lyrics != null) { + val playState by remember { playbackManager.state.playState }.collectAsState() LyricsDtoBox( - lyricDto = lyrics!!, - currentTimestamp = playbackPosition, - duration = playbackDuration, + lyricDto = lyrics, + currentTimestamp = playbackManager.state.positionInfo.active, + duration = playbackManager.state.positionInfo.duration, paused = playState != PlayState.PLAYING, fontSize = 12.sp, color = Color.White,