Basic sync lyric support

This commit is contained in:
Damontecres 2026-02-28 16:19:05 -05:00
parent bd7fba14d1
commit 49adae9365
No known key found for this signature in database
7 changed files with 330 additions and 70 deletions

View file

@ -87,6 +87,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
@ -321,7 +323,13 @@ class MainActivity : AppCompatActivity() {
server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
// ?: Destination.Home(),
// TODO undo
?: Destination.MediaItem(
itemId = "7e64e319-657a-9516-ec78-490da03edccb".toUUID(),
type = BaseItemKind.COLLECTION_FOLDER,
collectionType = CollectionType.MUSIC,
),
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),

View file

@ -14,6 +14,7 @@ data class AudioItem(
val artistNames: String?,
val runtime: Duration?,
val imageUrl: String?,
val hasLyrics: Boolean,
) {
companion object {
fun from(
@ -28,6 +29,7 @@ data class AudioItem(
artistNames = item.data.albumArtist,
runtime = item.data.runTimeTicks?.ticks,
imageUrl = imageUrl,
hasLyrics = item.data.hasLyrics == true,
)
}
}

View file

@ -0,0 +1,73 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.ifElse
import org.jellyfin.sdk.model.api.LyricDto
import org.jellyfin.sdk.model.api.LyricLine
@Composable
fun LyricsContent(
lyrics: LyricDto?,
currentLyricPosition: Int?,
onClick: (LyricLine) -> Unit,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState(currentLyricPosition ?: 0)
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(currentLyricPosition) {
if (currentLyricPosition != null) {
bringIntoViewRequester.bringIntoView()
}
}
Column(modifier) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
if (lyrics?.lyrics?.isNotEmpty() == true) {
itemsIndexed(lyrics.lyrics) { index, lyric ->
val color by animateColorAsState(
if (index == currentLyricPosition || currentLyricPosition == null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
},
animationSpec = tween(durationMillis = 500, easing = LinearEasing),
)
Text(
text = lyric.text,
style = MaterialTheme.typography.bodyLarge,
color = color,
modifier =
Modifier.ifElse(
index == currentLyricPosition,
Modifier.bringIntoViewRequester(bringIntoViewRequester),
),
)
}
}
}
}
}

View file

@ -24,8 +24,8 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberPreviousButtonState
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.services.MusicServiceState
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.SeekBar
import com.github.damontecres.wholphin.ui.roundSeconds
@ -36,7 +36,7 @@ import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class)
@Composable
fun NowPlayingOverlay(
state: MusicServiceState,
state: NowPlayingState,
player: Player,
current: AudioItem?,
queue: List<AudioItem>,
@ -79,7 +79,23 @@ fun NowPlayingOverlay(
player = player,
initialFocusRequester = focusRequester,
onControllerInteraction = { controllerViewState.pulseControls() },
onPlaybackActionClick = {},
onPlaybackActionClick = {
when (it) {
PlaybackAction.Next -> {
nextState.onClick()
}
PlaybackAction.Previous -> {
previousState.onClick()
}
is PlaybackAction.ToggleCaptions -> {
TODO()
}
else -> {}
}
},
showPlay = playPauseState.showPlay,
previousEnabled = previousState.isEnabled,
nextEnabled = nextState.isEnabled,

View file

@ -1,82 +1,40 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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 androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.rememberQueue
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
class NowPlayingViewModel
@AssistedInject
constructor(
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService,
private val musicService: MusicService,
val userPreferencesService: UserPreferencesService,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(): NowPlayingViewModel
}
val controllerViewState =
ControllerViewState(
AppPreference.ControllerTimeout.defaultValue,
true,
)
val state get() = musicService.state
val player get() = musicService.player
init {
}
fun reportInteraction() {
controllerViewState.pulseControls()
}
}
@OptIn(UnstableApi::class)
@Composable
fun NowPlayingPage(
@ -88,8 +46,8 @@ fun NowPlayingPage(
) {
val state by viewModel.state.collectAsState()
val player = viewModel.player
val queue = rememberQueue(player, state.queueSize)
val current = queue.getOrNull(state.currentIndex)
val queue = rememberQueue(player, state.musicServiceState.queueSize)
val current = queue.getOrNull(state.musicServiceState.currentIndex)
val controllerViewState = viewModel.controllerViewState
val preferences =
@ -124,23 +82,51 @@ fun NowPlayingPage(
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(modifier) {
Box(
modifier =
modifier
Modifier
.fillMaxSize()
.onPreviewKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
AnimatedVisibility(
visible = state.lyrics != null && state.lyrics?.lyrics?.isNotEmpty() == true,
enter = expandHorizontally(expandFrom = Alignment.Start),
exit = shrinkHorizontally(shrinkTowards = Alignment.Start),
modifier = Modifier,
) {
LyricsContent(
lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex,
onClick = {},
modifier =
Modifier
.padding(vertical = 120.dp, horizontal = 32.dp)
.fillMaxHeight()
.width(320.dp),
)
}
AsyncImage(
contentDescription = null,
model = current?.imageUrl,
modifier =
Modifier
.padding(80.dp)
.fillMaxSize(),
.fillMaxSize()
.weight(1f),
)
}
}
BackHandler(controllerViewState.controlsVisible) {
controllerViewState.hideControls()
}
AnimatedVisibility(controllerViewState.controlsVisible) {
NowPlayingOverlay(
state = state,

View file

@ -0,0 +1,12 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.services.MusicServiceState
import org.jellyfin.sdk.model.api.LyricDto
@Stable
data class NowPlayingState(
val musicServiceState: MusicServiceState,
val lyrics: LyricDto? = null,
val currentLyricIndex: Int? = null,
)

View file

@ -0,0 +1,163 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.mayakapps.kache.InMemoryKache
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.lyricsApi
import org.jellyfin.sdk.model.api.LyricDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
class NowPlayingViewModel
@AssistedInject
constructor(
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService,
private val musicService: MusicService,
val userPreferencesService: UserPreferencesService,
) : ViewModel(),
Player.Listener {
@AssistedFactory
interface Factory {
fun create(): NowPlayingViewModel
}
val controllerViewState =
ControllerViewState(
AppPreference.Companion.ControllerTimeout.defaultValue,
true,
)
val state = MutableStateFlow(NowPlayingState(musicService.state.value))
val player get() = musicService.player
private val lyricCache =
InMemoryKache<UUID, LyricDto>(20) {
creationScope = CoroutineScope(Dispatchers.IO)
}
init {
player.addListener(this)
addCloseable { player.removeListener(this) }
viewModelScope.launchDefault {
musicService.state.collectLatest { musicServiceState ->
state.update { it.copy(musicServiceState = musicServiceState) }
}
}
playbackLoop()
}
fun reportInteraction() {
controllerViewState.pulseControls()
}
private suspend fun getCurrent(): AudioItem? {
val mediaItem =
onMain { player.currentMediaItemIndex.let { player.getMediaItemAt(it) } }
return mediaItem.localConfiguration?.tag as? AudioItem
}
private fun playbackLoop() {
viewModelScope.launchDefault {
while (isActive) {
val position = onMain { player.currentPosition }.milliseconds
// Timber.v("playbackLoop: %s", position)
getCurrent()?.let { audio ->
// Timber.v("Got current %s", audio.id)
if (audio.hasLyrics) {
val lyrics =
lyricCache.getOrPut(audio.id) {
// TODO remote lyrics?
api.lyricsApi.getLyrics(audio.id).content
}
val lyricIndex =
if (lyrics != null) {
val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO
val lyricPosition = offset + position
lyrics.lyrics
.indexOfLast {
it.start?.ticks?.let { lyricPosition >= it } == true
}.takeIf { it >= 0 }
} else {
null
}
Timber.v("lyricIndex=$lyricIndex")
state.update {
it.copy(
lyrics = lyrics,
currentLyricIndex = lyricIndex,
)
}
}
}
delay(250)
}
}
}
override fun onMediaItemTransition(
mediaItem: MediaItem?,
reason: Int,
) {
viewModelScope.launchDefault {
val audio = mediaItem?.localConfiguration?.tag as? AudioItem
Timber.v("onMediaItemTransition to %s", audio?.id)
state.update {
it.copy(
lyrics = null,
currentLyricIndex = null,
)
}
audio?.let { audio ->
if (audio.hasLyrics) {
val lyrics =
lyricCache.getOrPut(audio.id) {
// TODO remote lyrics?
api.lyricsApi.getLyrics(audio.id).content
}
Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null)
state.update {
it.copy(
lyrics = lyrics,
)
}
}
}
}
}
}