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 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue