From d13123c4e5a09a5be1ed87134a6d15c9cb5f5a73 Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Mon, 20 May 2024 12:49:05 +0200 Subject: [PATCH] Implement lyrics --- .../integration/dream/DreamViewModel.kt | 46 ++-- .../composable/DreamContentLibraryShowcase.kt | 2 +- .../composable/DreamContentNowPlaying.kt | 90 ++++--- .../dream/composable/DreamHeader.kt | 2 +- .../integration/dream/model/DreamContent.kt | 3 +- .../androidtv/ui/composable/LyricsBox.kt | 235 ++++++++++++++++++ .../ui/composable/modifier/fadingEdges.kt | 115 +++++++++ .../ui/composable/{ => modifier}/overscan.kt | 2 +- .../ui/playback/AudioNowPlayingFragment.java | 3 + .../playback/AudioNowPlayingFragmentHelper.kt | 82 ++++++ .../fragment/ConnectHelpAlertFragment.kt | 2 +- .../res/layout/fragment_audio_now_playing.xml | 8 + .../src/main/kotlin/JellyfinPlugin.kt | 2 + playback/jellyfin/src/main/kotlin/lyrics.kt | 46 ++++ 14 files changed, 573 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/androidtv/ui/composable/LyricsBox.kt create mode 100644 app/src/main/java/org/jellyfin/androidtv/ui/composable/modifier/fadingEdges.kt rename app/src/main/java/org/jellyfin/androidtv/ui/composable/{ => modifier}/overscan.kt (89%) create mode 100644 app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragmentHelper.kt create mode 100644 playback/jellyfin/src/main/kotlin/lyrics.kt 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 b70590b4e0..841c5f2062 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 @@ -8,28 +8,29 @@ import androidx.lifecycle.viewModelScope import coil.ImageLoader import coil.request.ImageRequest import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.callbackFlow 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.androidtv.ui.playback.AudioEventListener -import org.jellyfin.androidtv.ui.playback.MediaManager -import org.jellyfin.androidtv.ui.playback.PlaybackController +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.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageFormat import org.jellyfin.sdk.model.api.ImageType @@ -42,34 +43,27 @@ class DreamViewModel( private val api: ApiClient, private val imageLoader: ImageLoader, private val context: Context, - private val mediaManager: MediaManager, + playbackManager: PlaybackManager, private val userPreferences: UserPreferences, ) : ViewModel() { - private val _mediaContent = callbackFlow { - trySend(mediaManager.currentAudioItem) - - val listener = object : AudioEventListener { - override fun onPlaybackStateChange( - newState: PlaybackController.PlaybackState, - currentItem: BaseItemDto? - ) { - trySend(currentItem) - } - - override fun onQueueStatusChanged(hasQueue: Boolean) { - trySend(mediaManager.currentAudioItem) + private val QueueEntry.nowPlayingFlow + get() = combine(baseItemFlow, lyricsFlow) { baseItem, lyrics -> + baseItem?.let { + DreamContent.NowPlaying( + item = baseItem, + lyrics = lyrics, + ) } } - mediaManager.addAudioEventListener(listener) - awaitClose { mediaManager.removeAudioEventListener(listener) } - } + @OptIn(ExperimentalCoroutinesApi::class) + private val _mediaContent = playbackManager.queue.entry + .flatMapLatest { entry -> entry?.nowPlayingFlow ?: emptyFlow() } .distinctUntilChanged() - .map { it?.let(DreamContent::NowPlaying) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - DreamContent.NowPlaying(mediaManager.currentAudioItem) + null, ) private val _libraryContent = flow { diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLibraryShowcase.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLibraryShowcase.kt index 9e047685bc..9a3450ff52 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLibraryShowcase.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLibraryShowcase.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import org.jellyfin.androidtv.integration.dream.model.DreamContent import org.jellyfin.androidtv.ui.composable.ZoomBox -import org.jellyfin.androidtv.ui.composable.overscan +import org.jellyfin.androidtv.ui.composable.modifier.overscan @Composable fun DreamContentLibraryShowcase( 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 f2da23afda..1621a0708a 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,9 +14,10 @@ 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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState 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 androidx.compose.ui.Alignment @@ -30,20 +31,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.overscan -import org.jellyfin.androidtv.ui.playback.AudioEventListener -import org.jellyfin.androidtv.ui.playback.MediaManager +import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges +import org.jellyfin.androidtv.ui.composable.modifier.overscan +import org.jellyfin.playback.core.PlaybackManager +import org.jellyfin.playback.core.model.PlayState 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.jellyfin.sdk.model.extensions.ticks import org.koin.compose.koinInject import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @Composable fun DreamContentNowPlaying( @@ -52,18 +55,34 @@ fun DreamContentNowPlaying( modifier = Modifier.fillMaxSize(), ) { val api = koinInject() - val mediaManager = koinInject() - val item = content.item ?: return@Box + val playbackManager = koinInject() - val primaryImageTag = item.imageTags?.get(ImageType.PRIMARY) + // 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 primaryImageTag = content.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 + primaryImageTag != null -> content.item.id to primaryImageTag + (content.item.albumId != null && content.item.albumPrimaryImageTag != null) -> content.item.albumId to content.item.albumPrimaryImageTag else -> null to null } - val imageBlurHash = - imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) } + // Background + val imageBlurHash = imageTag?.let { tag -> + content.item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) + } if (imageBlurHash != null) { Image( painter = blurHashPainter(imageBlurHash, IntSize(32, 32)), @@ -76,7 +95,23 @@ fun DreamContentNowPlaying( DreamContentVignette() } - // Overlay + // Lyrics overlay (on top of background) + if (content.lyrics != null) { + LyricsDtoBox( + lyricDto = content.lyrics, + currentTimestamp = playbackPosition, + duration = playbackDuration, + paused = playState != PlayState.PLAYING, + fontSize = 22.sp, + color = Color.White, + modifier = Modifier + .fillMaxSize() + .fadingEdges(vertical = 250.dp) + .padding(horizontal = 50.dp), + ) + } + + // Metadata overlay (includes title / progress) Row( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(20.dp), @@ -105,7 +140,7 @@ fun DreamContentNowPlaying( .padding(bottom = 10.dp) ) { Text( - text = item.name.orEmpty(), + text = content.item.name.orEmpty(), style = TextStyle( color = Color.White, fontSize = 26.sp, @@ -113,7 +148,7 @@ fun DreamContentNowPlaying( ) Text( - text = item.run { + text = content.item.run { val artistNames = artists.orEmpty() val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty() @@ -131,22 +166,6 @@ fun DreamContentNowPlaying( Spacer(modifier = Modifier.height(10.dp)) - var progress by remember { mutableFloatStateOf(0f) } - DisposableEffect(Unit) { - val listener = object : AudioEventListener { - override fun onProgress(pos: Long) { - val duration = item.runTimeTicks?.ticks ?: Duration.ZERO - progress = (pos.milliseconds / duration).toFloat() - } - } - - mediaManager.addAudioEventListener(listener) - - onDispose { - mediaManager.removeAudioEventListener(listener) - } - } - Box( modifier = Modifier .fillMaxWidth() @@ -156,7 +175,10 @@ fun DreamContentNowPlaying( // Background drawRect(Color.White, alpha = 0.2f) // Foreground - drawRect(Color.White, size = size.copy(width = size.width * progress)) + drawRect( + Color.White, + size = size.copy(width = size.width * (playbackPosition.inWholeMilliseconds.toFloat() / playbackDuration.inWholeMilliseconds.toFloat())) + ) } ) } diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHeader.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHeader.kt index 26af6f3e50..d134c53a57 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHeader.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHeader.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import org.jellyfin.androidtv.R -import org.jellyfin.androidtv.ui.composable.overscan +import org.jellyfin.androidtv.ui.composable.modifier.overscan import org.jellyfin.androidtv.ui.composable.rememberCurrentTime @Composable 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 b59383a56a..ad7e4c6b2b 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 @@ -2,9 +2,10 @@ package org.jellyfin.androidtv.integration.dream.model import android.graphics.Bitmap 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?) : DreamContent + data class NowPlaying(val item: BaseItemDto, val lyrics: LyricDto?) : DreamContent } 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 new file mode 100644 index 0000000000..4af6f1c001 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/composable/LyricsBox.kt @@ -0,0 +1,235 @@ +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 +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measured +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalTextStyle +import androidx.tv.material3.Text +import org.jellyfin.sdk.model.api.LyricDto +import org.jellyfin.sdk.model.extensions.ticks +import kotlin.time.Duration + +data class TimedLine( + val timestamp: Duration, + val text: String, +) + +private data class LyricsBoxContentMeasurements( + val size: Size, + val items: List, +) + +private fun Collection.indexAtTimestamp(timestamp: Duration) = indexOfLast { it.timestamp <= timestamp }.takeIf { it != -1 } + +@Composable +private fun LyricsLine( + text: String, + fontSize: TextUnit, + color: Color, + active: Boolean = false, + gap: Dp = 15.dp, +) { + val color by animateColorAsState( + targetValue = if (active) color else color.copy(alpha = 0.5f), + animationSpec = tween(), + label = "LyricsLineColor", + ) + + val scale by animateFloatAsState( + targetValue = if (active) 1.5f else 1f, + animationSpec = tween(), + label = "LyricsLineScale", + ) + + Text( + text = text, + textAlign = TextAlign.Center, + fontSize = fontSize, + color = color, + modifier = Modifier + .padding(bottom = gap) + .scale(scale) + ) +} + +@Composable +private fun LyricsBoxContent( + items: Collection, + modifier: Modifier = Modifier, + onMeasured: ((measurements: LyricsBoxContentMeasurements) -> Unit)? = null, + itemContent: @Composable (item: T, index: Int) -> Unit, +) = Layout( + modifier = modifier, + measurePolicy = { measurables, constraints -> + val placeables = measurables.map { measurable -> + measurable.measure(constraints.copy(minWidth = constraints.maxWidth)) + } + if (onMeasured != null) { + val totalHeight = placeables.sumOf { it.height }.toFloat() + onMeasured(LyricsBoxContentMeasurements(Size(1f, totalHeight), placeables)) + } + + layout(constraints.maxWidth, constraints.maxHeight) { + // Start at the offset until the active line and half the height of this container to vertically center the item + var yOffset = constraints.maxHeight / 2 + + for (placeable in placeables) { + placeable.placeRelative( + x = 0, + y = yOffset, + ) + + yOffset += placeable.height + } + } + }, + + content = { + items.forEachIndexed { index, item -> + itemContent(item, index) + } + }, +) + +@Composable +fun LyricsBox( + lines: List, + currentTimestamp: Duration = Duration.ZERO, + fontSize: TextUnit = LocalTextStyle.current.fontSize, + color: Color = LocalTextStyle.current.color, +) { + var lineMeasurements by remember { mutableStateOf>(emptyList()) } + val activeLine = lines.indexAtTimestamp(currentTimestamp) + val activeLineOffsetAnimation = remember { Animatable(0f) } + + LaunchedEffect(activeLine) { + var offset = 0f + + if (activeLine != null) { + // Add offset for all previous items + offset += lineMeasurements.take(activeLine).sumOf { it.measuredHeight } + // Add offset for half-height to center item + offset += lineMeasurements.getOrNull(activeLine)?.measuredHeight?.div(2) ?: 0 + } + + activeLineOffsetAnimation.animateTo(offset) + } + + LyricsBoxContent( + items = lines, + modifier = Modifier.graphicsLayer { + translationY = -activeLineOffsetAnimation.value + }, + onMeasured = { measurements -> lineMeasurements = measurements.items } + ) { line, index -> + LyricsLine( + text = line.text, + active = index == activeLine, + fontSize = fontSize, + color = color, + ) + } +} + +@Composable +fun LyricsBox( + lines: List, + modifier: Modifier = Modifier, + currentTimestamp: Duration = Duration.ZERO, + duration: Duration = Duration.ZERO, + paused: Boolean = false, + fontSize: TextUnit = LocalTextStyle.current.fontSize, + 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, + ) + ) + } + } + + LyricsBoxContent( + items = lines, + modifier = Modifier.graphicsLayer { + translationY = activeLineOffsetAnimation.value + }, + onMeasured = { measurements -> totalHeight = measurements.size.height } + ) { line, index -> + LyricsLine( + text = line, + fontSize = fontSize, + color = color, + ) + } +} + +@Composable +fun LyricsDtoBox( + lyricDto: LyricDto, + modifier: Modifier = Modifier, + currentTimestamp: Duration = Duration.ZERO, + duration: Duration = Duration.ZERO, + paused: Boolean = false, + fontSize: TextUnit = LocalTextStyle.current.fontSize, + color: Color = LocalTextStyle.current.color, +) = Box(modifier) { + val lyrics = lyricDto.lyrics + val isTimed = lyrics.firstOrNull()?.start != null + if (isTimed) { + LyricsBox( + lines = lyrics.map { + TimedLine(it.start?.ticks ?: Duration.ZERO, it.text) + }, + currentTimestamp = currentTimestamp, + fontSize = fontSize, + color = color, + ) + } else { + LyricsBox( + lines = lyrics.map { it.text }, + currentTimestamp = currentTimestamp, + duration = duration, + paused = paused, + fontSize = fontSize, + color = color, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/composable/modifier/fadingEdges.kt b/app/src/main/java/org/jellyfin/androidtv/ui/composable/modifier/fadingEdges.kt new file mode 100644 index 0000000000..368889a87f --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/composable/modifier/fadingEdges.kt @@ -0,0 +1,115 @@ +package org.jellyfin.androidtv.ui.composable.modifier + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun Modifier.fadingEdges( + all: Dp +) = fadingEdges( + start = all, + top = all, + end = all, + bottom = all, +) + +fun Modifier.fadingEdges( + vertical: Dp = 0.dp, + horizontal: Dp = 0.dp, +) = fadingEdges( + start = horizontal, + top = vertical, + end = horizontal, + bottom = vertical, +) + +fun Modifier.fadingEdges( + start: Dp = 0.dp, + top: Dp = 0.dp, + end: Dp = 0.dp, + bottom: Dp = 0.dp, +) = then( + Modifier + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + + // Start edge + if (start.value > 0) { + drawRect( + brush = Brush.horizontalGradient( + 0f to Color.Transparent, + 1f to Color.Black, + endX = start.toPx(), + ), + blendMode = BlendMode.DstIn, + ) + } + + // Top edge + if (top.value > 0) { + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Transparent, + 1f to Color.Black, + endY = top.toPx(), + ), + blendMode = BlendMode.DstIn, + ) + } + + // End edge + if (end.value > 0) { + drawRect( + brush = Brush.horizontalGradient( + 0f to Color.Black, + 1f to Color.Transparent, + startX = size.width - end.toPx(), + ), + blendMode = BlendMode.DstIn, + ) + } + + // Bottom edge + if (bottom.value > 0) { + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Black, + 1f to Color.Transparent, + startY = size.height - bottom.toPx(), + ), + blendMode = BlendMode.DstIn + ) + } + } +) + +@Preview +@Composable +private fun FadingEdgePreview() { + Box( + modifier = Modifier + .size(100.dp) + .background(Color.Red) + ) { + Box( + modifier = Modifier + .size(50.dp) + .fadingEdges(10.dp, 0.dp, 25.dp, 3.dp) + .background(Color.Blue) + .align(Alignment.Center) + ) { } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/composable/overscan.kt b/app/src/main/java/org/jellyfin/androidtv/ui/composable/modifier/overscan.kt similarity index 89% rename from app/src/main/java/org/jellyfin/androidtv/ui/composable/overscan.kt rename to app/src/main/java/org/jellyfin/androidtv/ui/composable/modifier/overscan.kt index cae26ad31a..ec36718a67 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/composable/overscan.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/composable/modifier/overscan.kt @@ -1,4 +1,4 @@ -package org.jellyfin.androidtv.ui.composable +package org.jellyfin.androidtv.ui.composable.modifier import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragment.java b/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragment.java index cf7eef060e..0063bee98d 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragment.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragment.java @@ -42,6 +42,7 @@ import org.jellyfin.androidtv.util.KeyProcessor; import org.jellyfin.androidtv.util.TimeUtils; import org.jellyfin.androidtv.util.Utils; +import org.jellyfin.playback.core.PlaybackManager; import java.util.List; @@ -84,6 +85,7 @@ public class AudioNowPlayingFragment extends Fragment { private final Lazy backgroundService = inject(BackgroundService.class); private final Lazy mediaManager = inject(MediaManager.class); + private final Lazy playbackManager = inject(PlaybackManager.class); private final Lazy navigationRepository = inject(NavigationRepository.class); private final Lazy keyProcessor = inject(KeyProcessor.class); private final Lazy imageHelper = inject(ImageHelper.class); @@ -107,6 +109,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c mCurrentNdx = binding.track; mScrollView = binding.mainScroller; mCounter = binding.counter; + AudioNowPlayingFragmentHelperKt.initializeLyricsView(binding.poster, binding.lyrics, playbackManager.getValue()); mPlayPauseButton = binding.playPauseBtn; mPlayPauseButton.setContentDescription(getString(R.string.lbl_pause)); 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 new file mode 100644 index 0000000000..946c5e86e9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/AudioNowPlayingFragmentHelper.kt @@ -0,0 +1,82 @@ +package org.jellyfin.androidtv.ui.playback + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.fillMaxSize +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.playback.core.PlaybackManager +import org.jellyfin.playback.core.model.PlayState +import org.jellyfin.playback.core.queue.queue +import org.jellyfin.playback.jellyfin.lyricsFlow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +fun initializeLyricsView( + coverView: AsyncImageView, + lyricsView: ComposeView, + playbackManager: PlaybackManager, +) { + lyricsView.setContent { + val lyrics by remember { + @OptIn(ExperimentalCoroutinesApi::class) + playbackManager.queue.entry.flatMapLatest { entry -> + entry?.lyricsFlow ?: emptyFlow() + } + }.collectAsState(null) + + // Animate cover view alpha + val coverViewAlpha by animateFloatAsState( + label = "CoverViewAlpha", + targetValue = if (lyrics == null) 1f else 0.2f, + ) + 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) { + LyricsDtoBox( + lyricDto = lyrics!!, + currentTimestamp = playbackPosition, + duration = playbackDuration, + paused = playState != PlayState.PLAYING, + fontSize = 12.sp, + color = Color.White, + modifier = Modifier + .fillMaxSize() + .fadingEdges(vertical = 50.dp) + .padding(horizontal = 15.dp), + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/startup/fragment/ConnectHelpAlertFragment.kt b/app/src/main/java/org/jellyfin/androidtv/ui/startup/fragment/ConnectHelpAlertFragment.kt index 617c8d9117..26a89a830c 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/startup/fragment/ConnectHelpAlertFragment.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/startup/fragment/ConnectHelpAlertFragment.kt @@ -36,7 +36,7 @@ import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import org.jellyfin.androidtv.R -import org.jellyfin.androidtv.ui.composable.overscan +import org.jellyfin.androidtv.ui.composable.modifier.overscan @Composable private fun ConnectHelpAlert( diff --git a/app/src/main/res/layout/fragment_audio_now_playing.xml b/app/src/main/res/layout/fragment_audio_now_playing.xml index c1b2d499b1..c8299e9ba6 100644 --- a/app/src/main/res/layout/fragment_audio_now_playing.xml +++ b/app/src/main/res/layout/fragment_audio_now_playing.xml @@ -209,6 +209,14 @@ android:layout_marginEnd="60dp" android:background="@drawable/shape_card" /> + + ("LyricDto") + +/** + * Get or set the [LyricDto] for this [QueueEntry]. + */ +var QueueEntry.lyrics by element(lyricsKey) +val QueueEntry.lyricsFlow by elementFlow(lyricsKey) + +class LyricsPlayerService( + private val api: ApiClient, +) : PlayerService() { + override suspend fun onInitialize() { + // Load lyrics for an item as soon as it becomes the currently playing item + manager.queue.entry + .onEach { entry -> entry?.let { fetchLyrics(entry) } } + .launchIn(coroutineScope) + } + + private suspend fun fetchLyrics(entry: QueueEntry) { + // Already has lyrics! + if (entry.lyrics != null) return + + // BaseItem doesn't exist or doesn't have lyrics + val baseItem = entry.baseItem ?: return + if (baseItem.hasLyrics != true) return + + // Get via API + val lyrics by api.lyricsApi.getLyrics(baseItem.id) + entry.lyrics = lyrics + } +}