mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
WIP now playing overlay
This commit is contained in:
parent
1d47220991
commit
123ac5601b
5 changed files with 205 additions and 56 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue