WIP now playing overlay

This commit is contained in:
Damontecres 2026-02-24 15:03:00 -05:00
parent 1d47220991
commit 123ac5601b
No known key found for this signature in database
5 changed files with 205 additions and 56 deletions

View file

@ -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,
)
}
}

View file

@ -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<MusicServiceState> = _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<AudioItem>,
val currentIndex: Int,

View file

@ -15,6 +15,8 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
) {
val flow = preferencesDataStore.data
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()

View file

@ -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,
)
}
}
}
}
}

View file

@ -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,
)
}
}
)
}
}
}