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 albumTitle: String?,
val artistNames: String?, val artistNames: String?,
val runtime: Duration?, val runtime: Duration?,
val imageUrl: String?,
) { ) {
companion object { companion object {
fun from(item: BaseItem): AudioItem = fun from(
item: BaseItem,
imageUrl: String?,
): AudioItem =
AudioItem( AudioItem(
id = item.id, id = item.id,
albumId = item.data.albumId, albumId = item.data.albumId,
@ -21,6 +25,7 @@ data class AudioItem(
albumTitle = item.data.album, albumTitle = item.data.album,
artistNames = item.data.albumArtist, artistNames = item.data.albumArtist,
runtime = item.data.runTimeTicks?.ticks, runtime = item.data.runTimeTicks?.ticks,
imageUrl = imageUrl,
) )
} }
} }

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.runtime.Stable
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline 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.instantMixApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -42,6 +44,7 @@ class MusicService
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
) { ) {
private val _state = MutableStateFlow(MusicServiceState.EMPTY) private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow<MusicServiceState> = _state val state: StateFlow<MusicServiceState> = _state
@ -147,15 +150,23 @@ class MusicService
Codec.Audio.FLAC, Codec.Audio.FLAC,
), ),
) )
val imageUrl =
audio.data.albumId?.let { albumId ->
imageUrlService.getItemImageUrl(
itemId = albumId,
imageType = ImageType.PRIMARY,
)
}
return MediaItem return MediaItem
.Builder() .Builder()
.setUri(url) .setUri(url)
.setMediaId(audio.id.toServerString()) .setMediaId(audio.id.toServerString())
.setTag(AudioItem.from(audio)) .setTag(AudioItem.from(audio, imageUrl))
.build() .build()
} }
} }
@Stable
data class MusicServiceState( data class MusicServiceState(
val queue: List<AudioItem>, val queue: List<AudioItem>,
val currentIndex: Int, val currentIndex: Int,

View file

@ -15,6 +15,8 @@ class UserPreferencesService
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
) { ) {
val flow = preferencesDataStore.data
suspend fun getCurrent(): UserPreferences = suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig -> serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() 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 android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Column import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberNextButtonState import coil3.compose.AsyncImage
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import com.github.damontecres.wholphin.preferences.AppPreference
import androidx.media3.ui.compose.state.rememberPreviousButtonState import com.github.damontecres.wholphin.preferences.AppPreferences
import androidx.tv.material3.Text import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) @HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
class NowPlayingViewModel class NowPlayingViewModel
@ -46,18 +48,28 @@ class NowPlayingViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val musicService: MusicService, private val musicService: MusicService,
val userPreferencesService: UserPreferencesService,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(): NowPlayingViewModel fun create(): NowPlayingViewModel
} }
val controllerViewState =
ControllerViewState(
AppPreference.ControllerTimeout.defaultValue,
true,
)
val state get() = musicService.state val state get() = musicService.state
val player get() = musicService.player val player get() = musicService.player
fun reportInteraction() {
controllerViewState.pulseControls()
}
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@ -72,52 +84,56 @@ fun NowPlayingPage(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val player = viewModel.player val player = viewModel.player
val current = state.queue.getOrNull(state.currentIndex) 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 keyHandler =
val previousState = rememberPreviousButtonState(player) remember(preferences) {
val nextState = rememberNextButtonState(player) PlaybackKeyHandler(
Column(modifier = modifier.padding(16.dp)) {
Text(
text = current?.title ?: "",
)
Row {
PlaybackButtons(
player = player, player = player,
initialFocusRequester = remember { FocusRequester() }, controlsEnabled = true,
onControllerInteraction = {}, skipWithLeftRight = true,
onPlaybackActionClick = {}, seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds,
showPlay = playPauseState.showPlay, seekBack = preferences.playbackPreferences.skipBackMs.milliseconds,
previousEnabled = previousState.isEnabled, controllerViewState = controllerViewState,
nextEnabled = nextState.isEnabled, updateSkipIndicator = {},
seekBack = 10.seconds, skipBackOnResume = preferences.playbackPreferences.skipBackOnResume,
skipBackOnResume = null, onInteraction = viewModel::reportInteraction,
seekForward = 30.seconds, oneClickPause = preferences.playbackPreferences.oneClickPause,
) onStop = {
} player.stop()
if (state.queue.isEmpty()) { // viewModel.navigationManager.goBack()
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 = {}, onPlaybackDialogTypeClick = { },
modifier = Modifier,
) )
} }
}
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(),
)
} }
} }
} }