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 4a0dd890..0b170238 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 @@ -11,9 +11,13 @@ data class AudioItem( val albumTitle: String?, val artistNames: String?, val runtime: Duration?, + val imageUrl: String?, ) { companion object { - fun from(item: BaseItem): AudioItem = + fun from( + item: BaseItem, + imageUrl: String?, + ): AudioItem = AudioItem( id = item.id, albumId = item.data.albumId, @@ -21,6 +25,7 @@ data class AudioItem( albumTitle = item.data.album, artistNames = item.data.albumArtist, runtime = item.data.runTimeTicks?.ticks, + imageUrl = imageUrl, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index 89260aa0..75f6c964 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.annotation.OptIn +import androidx.compose.runtime.Stable import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline @@ -28,6 +29,7 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.instantMixApi import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -42,6 +44,7 @@ class MusicService @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, private val api: ApiClient, private val serverRepository: ServerRepository, + private val imageUrlService: ImageUrlService, ) { private val _state = MutableStateFlow(MusicServiceState.EMPTY) val state: StateFlow = _state @@ -147,15 +150,23 @@ class MusicService Codec.Audio.FLAC, ), ) + val imageUrl = + audio.data.albumId?.let { albumId -> + imageUrlService.getItemImageUrl( + itemId = albumId, + imageType = ImageType.PRIMARY, + ) + } return MediaItem .Builder() .setUri(url) .setMediaId(audio.id.toServerString()) - .setTag(AudioItem.from(audio)) + .setTag(AudioItem.from(audio, imageUrl)) .build() } } +@Stable data class MusicServiceState( val queue: List, val currentIndex: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 90d68e52..7ed51113 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -15,6 +15,8 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, ) { + val flow = preferencesDataStore.data + suspend fun getCurrent(): UserPreferences = serverRepository.currentUserDto.value!!.configuration.let { userConfig -> val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() 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 new file mode 100644 index 00000000..cd2c61b2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -0,0 +1,115 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.annotation.OptIn +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberNextButtonState +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import androidx.media3.ui.compose.state.rememberPreviousButtonState +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.services.MusicServiceState +import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.ui.playback.PlaybackButtons +import com.github.damontecres.wholphin.ui.playback.SeekBar +import com.github.damontecres.wholphin.ui.roundSeconds +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(UnstableApi::class) +@Composable +fun NowPlayingOverlay( + state: MusicServiceState, + player: Player, + controllerViewState: ControllerViewState, + modifier: Modifier = Modifier, +) { + val current = state.queue.getOrNull(state.currentIndex) + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val playPauseState = rememberPlayPauseButtonState(player) + val previousState = rememberPreviousButtonState(player) + val nextState = rememberNextButtonState(player) + Column(modifier = modifier.padding(16.dp)) { + current?.title?.let { + Text(it) + } + current?.albumTitle?.let { + Text(it) + } + current?.artistNames?.let { + Text(it) + } + SeekBar( + player = player, + controllerViewState = controllerViewState, + onSeekProgress = {}, + interactionSource = remember { MutableInteractionSource() }, + isEnabled = false, + intervals = 0, + seekBack = Duration.ZERO, + seekForward = Duration.ZERO, + modifier = + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth(.95f), + ) + Row( + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + PlaybackButtons( + player = player, + initialFocusRequester = focusRequester, + onControllerInteraction = {}, + onPlaybackActionClick = {}, + showPlay = playPauseState.showPlay, + previousEnabled = previousState.isEnabled, + nextEnabled = nextState.isEnabled, + seekBack = 10.seconds, + skipBackOnResume = null, + seekForward = 30.seconds, + ) + } + if (state.queue.isEmpty()) { + Text("No items") + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(state.queue) { index, song -> + SongListItem( + title = song.title, + artist = song.artistNames, + indexNumber = index + 1, + runtime = song.runtime?.roundSeconds, + showArtist = true, + isPlaying = state.currentIndex == index, + onClick = { + player.seekTo(index, 0L) + }, + onLongClick = {}, + modifier = Modifier, + ) + } + } + } + } +} 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 c3185310..0851bc69 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 @@ -2,41 +2,43 @@ package com.github.damontecres.wholphin.ui.detail.music import android.content.Context import androidx.annotation.OptIn -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed 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.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 androidx.media3.ui.compose.state.rememberNextButtonState -import androidx.media3.ui.compose.state.rememberPlayPauseButtonState -import androidx.media3.ui.compose.state.rememberPreviousButtonState -import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +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.ui.playback.PlaybackButtons -import com.github.damontecres.wholphin.ui.roundSeconds +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.seconds +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) class NowPlayingViewModel @@ -46,18 +48,28 @@ class NowPlayingViewModel @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, private val favoriteWatchManager: FavoriteWatchManager, - private val userPreferencesService: UserPreferencesService, 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 + + fun reportInteraction() { + controllerViewState.pulseControls() + } } @OptIn(UnstableApi::class) @@ -72,52 +84,56 @@ fun NowPlayingPage( val state by viewModel.state.collectAsState() val player = viewModel.player val current = state.queue.getOrNull(state.currentIndex) + val controllerViewState = viewModel.controllerViewState + val preferences by viewModel.userPreferencesService.flow.collectAsState(AppPreferences.getDefaultInstance()) - val playPauseState = rememberPlayPauseButtonState(player) - val previousState = rememberPreviousButtonState(player) - val nextState = rememberNextButtonState(player) - - Column(modifier = modifier.padding(16.dp)) { - Text( - text = current?.title ?: "", - ) - Row { - PlaybackButtons( + val keyHandler = + remember(preferences) { + PlaybackKeyHandler( player = player, - initialFocusRequester = remember { FocusRequester() }, - onControllerInteraction = {}, - onPlaybackActionClick = {}, - showPlay = playPauseState.showPlay, - previousEnabled = previousState.isEnabled, - nextEnabled = nextState.isEnabled, - seekBack = 10.seconds, - skipBackOnResume = null, - seekForward = 30.seconds, + controlsEnabled = true, + skipWithLeftRight = true, + seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, + controllerViewState = controllerViewState, + updateSkipIndicator = {}, + skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, + onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.playbackPreferences.oneClickPause, + onStop = { + player.stop() +// viewModel.navigationManager.goBack() + }, + onPlaybackDialogTypeClick = { }, ) } - if (state.queue.isEmpty()) { - Text("No items") - } else { - LazyColumn( - contentPadding = PaddingValues(16.dp), + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + + Box( + modifier = + modifier + .onPreviewKeyEvent(keyHandler::onKeyEvent) + .focusRequester(focusRequester) + .focusable(), + ) { + AsyncImage( + contentDescription = null, + model = current?.imageUrl, + modifier = + Modifier + .padding(80.dp) + .fillMaxSize(), + ) + + AnimatedVisibility(controllerViewState.controlsVisible) { + NowPlayingOverlay( + state = state, + player = player, + controllerViewState = controllerViewState, modifier = Modifier.fillMaxSize(), - ) { - itemsIndexed(state.queue) { index, song -> - SongListItem( - title = song.title, - artist = song.artistNames, - indexNumber = index + 1, - runtime = song.runtime?.roundSeconds, - showArtist = true, - isPlaying = state.currentIndex == index, - onClick = { - player.seekTo(index, 0L) - }, - onLongClick = {}, - modifier = Modifier, - ) - } - } + ) } } }