diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 5a6e446f..6596ba3f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt index 1e11aeaf..fbcc8e6d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt @@ -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, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt new file mode 100644 index 00000000..7efeea1c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -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), + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt index b6a73fa6..a0c7c553 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -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, @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 9da83cc9..fcaae4d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -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 = - modifier - .onPreviewKeyEvent(keyHandler::onKeyEvent) - .focusRequester(focusRequester) - .focusable(), - ) { - AsyncImage( - contentDescription = null, - model = current?.imageUrl, + Box(modifier) { + Box( modifier = Modifier - .padding(80.dp) - .fillMaxSize(), - ) + .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() + .weight(1f), + ) + } + } + BackHandler(controllerViewState.controlsVisible) { + controllerViewState.hideControls() + } AnimatedVisibility(controllerViewState.controlsVisible) { NowPlayingOverlay( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt new file mode 100644 index 00000000..c2bda912 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt new file mode 100644 index 00000000..f84032c7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -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(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, + ) + } + } + } + } + } + }