Skip to content

Commit

Permalink
Implement lyrics
Browse files Browse the repository at this point in the history
  • Loading branch information
nielsvanvelzen committed Oct 26, 2024
1 parent 7b51b84 commit 786110b
Show file tree
Hide file tree
Showing 14 changed files with 573 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -52,18 +55,34 @@ fun DreamContentNowPlaying(
modifier = Modifier.fillMaxSize(),
) {
val api = koinInject<ApiClient>()
val mediaManager = koinInject<MediaManager>()
val item = content.item ?: return@Box
val playbackManager = koinInject<PlaybackManager>()

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)),
Expand All @@ -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),
Expand Down Expand Up @@ -105,15 +140,15 @@ fun DreamContentNowPlaying(
.padding(bottom = 10.dp)
) {
Text(
text = item.name.orEmpty(),
text = content.item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 26.sp,
),
)

Text(
text = item.run {
text = content.item.run {
val artistNames = artists.orEmpty()
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()

Expand All @@ -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()
Expand All @@ -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()))
)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading

0 comments on commit 786110b

Please sign in to comment.