UI improvements & bug fixes for queuing

This commit is contained in:
Damontecres 2026-02-28 17:47:01 -05:00
parent 49adae9365
commit 82f8a63c86
No known key found for this signature in database
7 changed files with 267 additions and 60 deletions

View file

@ -87,7 +87,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@ -326,9 +325,8 @@ class MainActivity : AppCompatActivity() {
// ?: Destination.Home(),
// TODO undo
?: Destination.MediaItem(
itemId = "7e64e319-657a-9516-ec78-490da03edccb".toUUID(),
type = BaseItemKind.COLLECTION_FOLDER,
collectionType = CollectionType.MUSIC,
itemId = "011ef0c7-ca45-684f-2cd9-dd3b020ca5f6".toUUID(),
type = BaseItemKind.MUSIC_ALBUM,
),
navigationManager = navigationManager,
preferences = preferences,

View file

@ -99,7 +99,7 @@ class MusicService
player.shuffleModeEnabled = shuffled
player.play()
}
addAllToQueue(items)
addAllToQueue(items, startIndex)
}
suspend fun setQueue(
@ -140,16 +140,26 @@ class MusicService
}
}
suspend fun addAllToQueue(list: BlockingList<BaseItem?>) {
suspend fun addAllToQueue(
list: BlockingList<BaseItem?>,
startIndex: Int,
) {
var remaining = startIndex
list.indices
.chunked(25)
.forEach {
val mediaItems =
it.mapNotNull {
list
.getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO }
?.let(::convert)
if (remaining == 0) {
list
.getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO }
?.let(::convert)
} else {
Timber.v("Skipping $remaining")
remaining--
null
}
}
onMain { player.addMediaItems(mediaItems) }
}
@ -243,18 +253,24 @@ private class MusicPlayerListener(
timeline: Timeline,
reason: Int,
) {
Timber.v("MusicPlayerListener onTimelineChanged")
// Timber.v("MusicPlayerListener onTimelineChanged")
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
updateCurrentIndex()
}
}
private fun updateCurrentIndex() {
state.update {
it.copy(
currentIndex = player.currentMediaItemIndex,
currentItemId = player.getMediaItemAt(player.currentMediaItemIndex).mediaId.toUUIDOrNull(),
)
state.update { state ->
player.currentMediaItemIndex.takeIf { it >= 0 }?.let { currentMediaItemIndex ->
if (currentMediaItemIndex in (0..<player.mediaItemCount)) {
state.copy(
currentIndex = currentMediaItemIndex,
currentItemId = player.getMediaItemAt(currentMediaItemIndex).mediaId.toUUIDOrNull(),
)
} else {
state
}
} ?: state
}
}
}

View file

@ -1,35 +1,59 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
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.fillMaxHeight
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.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.media3.ui.compose.state.rememberRepeatButtonState
import androidx.media3.ui.compose.state.rememberShuffleButtonState
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.PlaybackFaButton
import com.github.damontecres.wholphin.ui.playback.SeekBar
import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@ -43,20 +67,62 @@ fun NowPlayingOverlay(
controllerViewState: ControllerViewState,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
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)) {
val shuffleState = rememberShuffleButtonState(player)
val repeatState = rememberRepeatButtonState(player)
var queueHasFocus by remember { mutableStateOf(false) }
val height by animateFloatAsState(
if (queueHasFocus) {
1f
} else {
.5f
},
animationSpec = tween(durationMillis = 500),
)
val listState = rememberLazyListState()
val hideButtons by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
val firstFocusRequester = remember { FocusRequester() }
BackHandler(hideButtons) {
scope.launch {
listState.animateScrollToItem(0)
firstFocusRequester.tryRequestFocus()
}
}
Column(
modifier =
modifier
.padding(16.dp)
.fillMaxHeight(height),
) {
current?.title?.let {
Text(it)
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
)
}
current?.albumTitle?.let {
Text(it)
Text(
text = it,
style = MaterialTheme.typography.titleSmall,
)
}
current?.artistNames?.let {
Text(it)
Text(
text = it,
style = MaterialTheme.typography.titleSmall,
)
}
SeekBar(
player = player,
@ -69,47 +135,97 @@ fun NowPlayingOverlay(
seekForward = Duration.ZERO,
modifier =
Modifier
.padding(vertical = 8.dp)
.padding(top = 8.dp)
.fillMaxWidth(.95f),
)
Row(
modifier = Modifier.align(Alignment.CenterHorizontally),
AnimatedVisibility(
visible = !hideButtons,
enter = expandVertically(),
exit = shrinkVertically(),
modifier =
Modifier
.align(Alignment.CenterHorizontally),
) {
PlaybackButtons(
player = player,
initialFocusRequester = focusRequester,
onControllerInteraction = { controllerViewState.pulseControls() },
onPlaybackActionClick = {
when (it) {
PlaybackAction.Next -> {
nextState.onClick()
}
Row(
modifier =
Modifier
.align(Alignment.CenterHorizontally),
) {
PlaybackButtons(
player = player,
initialFocusRequester = focusRequester,
onControllerInteraction = { controllerViewState.pulseControls() },
onPlaybackActionClick = {
when (it) {
PlaybackAction.Next -> {
nextState.onClick()
}
PlaybackAction.Previous -> {
previousState.onClick()
}
PlaybackAction.Previous -> {
previousState.onClick()
}
is PlaybackAction.ToggleCaptions -> {
TODO()
}
is PlaybackAction.ToggleCaptions -> {
TODO()
}
else -> {}
}
},
showPlay = playPauseState.showPlay,
previousEnabled = previousState.isEnabled,
nextEnabled = nextState.isEnabled,
seekBack = 10.seconds,
skipBackOnResume = null,
seekForward = 30.seconds,
)
else -> {}
}
},
showPlay = playPauseState.showPlay,
previousEnabled = previousState.isEnabled,
nextEnabled = nextState.isEnabled,
seekBack = 10.seconds,
skipBackOnResume = null,
seekForward = 30.seconds, // TODO
)
PlaybackFaButton(
iconRes = R.string.fa_shuffle,
onClick = {
shuffleState.onClick()
},
onControllerInteraction = { controllerViewState.pulseControls() },
textColor =
if (shuffleState.shuffleOn) {
MaterialTheme.colorScheme.secondary
} else {
Color.Unspecified
},
)
PlaybackFaButton(
iconRes = R.string.fa_repeat,
onClick = {
repeatState.onClick()
},
onControllerInteraction = { controllerViewState.pulseControls() },
textColor =
when (repeatState.repeatModeState) {
Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.secondary
// TODO
Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.tertiary
else -> Color.Unspecified
},
)
}
}
if (queue.isEmpty()) {
Text("No items")
} else {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleMedium,
)
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
modifier =
Modifier
.fillMaxSize()
.onFocusChanged {
queueHasFocus = it.hasFocus
},
) {
itemsIndexed(queue) { index, song ->
SongListItem(
@ -123,7 +239,11 @@ fun NowPlayingOverlay(
player.seekTo(index, 0L)
},
onLongClick = {},
modifier = Modifier,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(firstFocusRequester),
),
)
}
}

View file

@ -5,6 +5,7 @@ import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.services.rememberQueue
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration.Companion.milliseconds
@ -127,14 +129,22 @@ fun NowPlayingPage(
BackHandler(controllerViewState.controlsVisible) {
controllerViewState.hideControls()
}
AnimatedVisibility(controllerViewState.controlsVisible) {
AnimatedVisibility(
controllerViewState.controlsVisible,
modifier =
Modifier
.align(Alignment.BottomCenter),
) {
NowPlayingOverlay(
state = state,
player = player,
current = current,
queue = queue,
controllerViewState = controllerViewState,
modifier = Modifier.fillMaxSize(),
modifier =
Modifier
.background(AppColors.TransparentBlack50)
.align(Alignment.BottomCenter),
)
}
}

View file

@ -87,8 +87,12 @@ class NowPlayingViewModel
private suspend fun getCurrent(): AudioItem? {
val mediaItem =
onMain { player.currentMediaItemIndex.let { player.getMediaItemAt(it) } }
return mediaItem.localConfiguration?.tag as? AudioItem
onMain {
player.currentMediaItemIndex
.takeIf { it >= 0 }
?.let { player.getMediaItemAt(it) }
}
return mediaItem?.localConfiguration?.tag as? AudioItem
}
private fun playbackLoop() {
@ -125,7 +129,7 @@ class NowPlayingViewModel
}
}
delay(250)
delay(150)
}
}
}

View file

@ -5,10 +5,10 @@ package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
import androidx.annotation.DrawableRes
import androidx.annotation.OptIn
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -24,7 +24,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@ -37,14 +36,18 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
@ -60,6 +63,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
@ -462,6 +466,54 @@ fun PlaybackButton(
}
}
@Composable
fun PlaybackFaButton(
@StringRes iconRes: Int,
onClick: () -> Unit,
onControllerInteraction: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
textColor: Color = Color.Unspecified,
) {
val selectedColor = MaterialTheme.colorScheme.border
Button(
enabled = enabled,
onClick = onClick,
// shape = ButtonDefaults.shape(CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = AppColors.TransparentBlack25,
focusedContainerColor = selectedColor,
),
contentPadding = PaddingValues(4.dp),
interactionSource = interactionSource,
modifier =
modifier
.size(36.dp, 36.dp)
.onFocusChanged { onControllerInteraction.invoke() },
) {
Text(
text = stringResource(iconRes),
fontSize = 18.sp,
fontFamily = FontAwesome,
textAlign = TextAlign.Center,
color =
if (textColor.isSpecified) {
textColor
} else if (LocalTheme.current == AppThemeColors.OLED_BLACK) {
LocalContentColor.current
} else {
MaterialTheme.colorScheme.onSurface
},
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.CenterVertically),
)
}
}
@Composable
fun <T> BottomDialog(
choices: List<BottomDialogItem<T>>,
@ -545,12 +597,18 @@ data class BottomDialogItem<T>(
@Composable
private fun ButtonPreview() {
WholphinTheme {
Row {
Row(Modifier.background(Color.Red)) {
PlaybackButton(
iconRes = R.drawable.baseline_play_arrow_24,
onClick = {},
onControllerInteraction = {},
)
PlaybackFaButton(
iconRes = R.string.fa_shuffle,
onClick = {},
onControllerInteraction = {},
textColor = Color.Green,
)
}
}
}

View file

@ -54,4 +54,5 @@
<string name="fa_cloud_arrow_up" translatable="false">&#xf0ee;</string>
<string name="fa_cloud_arrow_down" translatable="false">&#xf0ed;</string>
<string name="fa_compass" translatable="false">&#xf14e;</string>
<string name="fa_repeat" translatable="false">&#xf363;</string>
</resources>