Updates to the playback debug overlay (#1290)

## Description
Lots of minor changes to the debug overlay

- Adjusts the size and layout 
- Smaller font size
- Only shows selected tracks by default
- Expanding tracks are scrollable and don't cover controls
- Show (estimated) network bitrate
- Show heap memory usage
- Better controls/chapters/queue animations
- Minor optimizations

Dev note: There's lots of code reorganization too

### Related issues
Related to #1216
Related to https://github.com/damontecres/Wholphin/issues/528

### Testing
Emulator & nvidia shield

## Screenshots
### Selected tracks
<img width="1280" height="720" alt="overlay1 Large"
src="https://github.com/user-attachments/assets/e6ef71f5-73ef-4947-8b2a-0f41f64ef019"
/>

### Expanded tracks, scrolled down
<img width="1280" height="720" alt="overlay2 Large"
src="https://github.com/user-attachments/assets/2e655f7e-7331-42dd-a572-0173b06563a8"
/>

### Transcode info
<img width="1280" height="720" alt="overlay3 Large"
src="https://github.com/user-attachments/assets/3081e2ec-bb9e-422c-b833-c22785c649bf"
/>

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-04-24 15:51:43 -04:00 committed by GitHub
parent 0f9d458bc7
commit 50174db22e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1589 additions and 1013 deletions

View file

@ -0,0 +1,33 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
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.focus.onFocusChanged
import androidx.compose.ui.unit.dp
/**
* An invisible, no size Box which can be focused.
*
* Used to allow for transitioning to restore hidden content
*/
@Composable
fun HiddenFocusBox(
focusRequester: FocusRequester = remember { FocusRequester() },
onFocus: () -> Unit,
) = Box(
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) onFocus.invoke()
}.focusable(),
)

View file

@ -7,7 +7,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
@ -363,15 +363,9 @@ fun CollectionDetailsContent(
) {
// This box exists so that there is something focusable above the item content
// allowing focus to move up to restore the collection's header
Box(
modifier =
Modifier
.fillMaxWidth()
.height(0.dp)
.onFocusChanged {
if (it.isFocused) itemsContentHasFocus = false
}.focusable(),
)
HiddenFocusBox {
itemsContentHasFocus = false
}
if (state.viewOptions.cardViewOptions.showDetails) {
HomePageHeader(
item = focusedItem,

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.annotation.OptIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@ -22,7 +20,6 @@ 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.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -35,7 +32,6 @@ 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.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -45,11 +41,10 @@ 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.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import com.github.damontecres.wholphin.ui.playback.buttonSpacing
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlin.time.Duration.Companion.seconds

View file

@ -52,7 +52,7 @@ import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.SeekBar
import com.github.damontecres.wholphin.ui.playback.overlay.SeekBar
import com.github.damontecres.wholphin.ui.preferences.MoveButton
import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.tryRequestFocus

View file

@ -65,10 +65,10 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.nav.Backdrop
import com.github.damontecres.wholphin.ui.playback.BottomDialog
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.extensions.ticks

View file

@ -1,144 +0,0 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.TranscodingInfo
import timber.log.Timber
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackDebugOverlay(
currentPlayback: CurrentPlayback?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val display =
remember(context) {
try {
val displayManager =
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
} catch (ex: Exception) {
Timber.e(ex)
null
}
}
val displayMode by produceState<String?>(null) {
while (isActive) {
value =
display?.mode?.let {
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
}
delay(10.seconds)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(48.dp),
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
) {
ProvideTextStyle(MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)) {
SimpleTable(
buildList {
add("Backend:" to currentPlayback?.backend?.toString())
add("Play method:" to currentPlayback?.playMethod?.serialName)
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
add("Video Decoder:" to currentPlayback.videoDecoder)
add("Audio Decoder:" to currentPlayback.audioDecoder)
}
add("Display Mode: " to displayMode?.toString())
},
modifier = Modifier.weight(1f, fill = false),
)
currentPlayback?.transcodeInfo?.let {
TranscodeInfo(it, Modifier.weight(2f))
}
}
}
currentPlayback?.tracks?.letNotEmpty {
PlaybackTrackInfo(
trackSupport = it,
)
}
}
}
@Composable
fun TranscodeInfo(
info: TranscodingInfo,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
SimpleTable(
listOf(
"Reason:" to info.transcodeReasons.joinToString(", "),
"HW Accel:" to info.hardwareAccelerationType?.toString(),
"Container:" to info.container,
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
),
)
SimpleTable(
listOf(
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
"Video Direct:" to info.isVideoDirect.toString(),
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
"Audio Direct:" to info.isAudioDirect.toString(),
),
)
}
}
@Composable
fun SimpleTable(
rows: List<Pair<String, String?>>,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
rows.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = it.first,
modifier = Modifier.width(100.dp),
)
Text(
text = it.second.toString(),
modifier = Modifier,
)
}
}
}
}

View file

@ -36,6 +36,9 @@ import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration
@ -65,10 +68,10 @@ data class PlaybackSettings(
/**
* Centralized UI component for displaying dialogs during playback
*
* Typically, the user will click something generating a [PlaybackAction] which translates into the
* Typically, the user will click something generating a [com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction] which translates into the
* [PlaybackDialogType] determining which dialog is shown by this component.
*
* @see PlaybackAction
* @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
*/
@Composable
fun PlaybackDialog(

View file

@ -1,705 +0,0 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
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
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo
import java.time.LocalTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val titleTextSize = 28.sp
private val subtitleTextSize = 18.sp
/**
* The overlay during playback showing controls, seek preview image, debug info, etc
*/
@Composable
fun PlaybackOverlay(
item: BaseItem?,
chapters: List<Chapter>,
playerControls: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
showClock: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean,
currentPlayback: CurrentPlayback?,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
onClickPlaylist: (BaseItem) -> Unit = {},
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
// Will be used for preview/trick play images
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
val density = LocalDensity.current
val titleHeight =
remember(item?.title) {
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight =
remember(item?.subtitleLong) {
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
}
// This will be calculated after composition
var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter,
) {
AnimatedVisibility(
visible = controllerViewState.controlsVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize(),
) {
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(scrimBrush),
)
}
AnimatedVisibility(
visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (seekBarFocused) {
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
val nextState =
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else if (playlist.hasNext()) {
OverlayViewState.QUEUE
} else {
null
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(bottom = 8.dp)
.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() }
},
) {
Controller(
title = item?.title,
subtitle = item?.subtitleLong,
playerControls = playerControls,
controllerViewState = controllerViewState,
showPlay = showPlay,
showClock = showClock,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekProgress = {
onSeekBarChange(it)
seekProgressMs = it
},
seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState,
onNextStateFocus = {
nextState?.let { state = it }
},
currentSegment = currentSegment,
modifier =
Modifier,
// Don't use key events because this control has vertical items so up/down is tough to manage
)
when (nextState) {
OverlayViewState.CHAPTERS -> {
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) state = nextState
}.focusable(),
)
}
OverlayViewState.QUEUE -> {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) state = nextState
}.focusable(),
)
}
else -> {
Spacer(Modifier.height(32.dp))
}
}
}
}
AnimatedVisibility(
visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (chapters.isNotEmpty()) {
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val chapterIndex =
remember {
val position = playerControls.currentPosition.milliseconds
val index =
chapters
.indexOfFirst { it.position > position }
.minus(1)
.let {
if (it < 0) {
// Didn't find a chapter, so it's either the first or last
if (position < chapters.first().position) {
0
} else {
chapters.lastIndex
}
} else {
it
}
}.coerceIn(0, chapters.lastIndex)
index
}
val listState = rememberLazyListState(chapterIndex)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) {
state = OverlayViewState.CONTROLLER
true
} else {
false
}
},
) {
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
state = listState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(chapters) { index, chapter ->
val interactionSource = chapterInteractionSources[index]
val isFocused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
ChapterCard(
name = chapter.name,
position = chapter.position,
imageUrl = chapter.imageUrl,
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
onClick = {
playerControls.seekTo(chapter.position.inWholeMilliseconds)
controllerViewState.hideControls()
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
index == chapterIndex,
Modifier
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester),
).ifElse(
index == 0,
Modifier.focusProperties {
// Prevent scrolling left on first card to prevent moving down
left = FocusRequester.Cancel
},
),
)
}
}
if (playlist.hasNext()) {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(bottom = 8.dp)
.onFocusChanged {
if (it.isFocused) state = OverlayViewState.QUEUE
}.focusable(),
)
}
}
}
}
AnimatedVisibility(
visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (playlist.hasNext()) {
val items = remember { playlist.upcomingItems() }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(8.dp)
.fillMaxWidth()
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) {
if (chapters.isNotEmpty()) {
state = OverlayViewState.CHAPTERS
} else {
state = OverlayViewState.CONTROLLER
}
true
} else if (isDown(e)) {
true
} else {
false
}
},
) {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(items) { index, item ->
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
SeasonCard(
item = item,
onClick = {
onClickPlaylist.invoke(item)
controllerViewState.hideControls()
},
onLongClick = {},
imageHeight = 140.dp,
interactionSource = interactionSource,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
}
}
// Trickplay
AnimatedVisibility(
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.fillMaxWidth(.95f),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
) {
if (trickplayInfo != null) {
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val imageUrl = remember(index) { trickplayUrlFor(index) }
if (imageUrl != null) {
SeekPreviewImage(
modifier = Modifier,
previewImageUrl = imageUrl,
seekProgressMs = seekProgressMs,
trickPlayInfo = trickplayInfo,
)
}
}
Text(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.background(
Color.Black.copy(alpha = 0.6f),
shape = RoundedCornerShape(4.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
// Top
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility(
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
AsyncImage(
model = logoImageUrl,
contentDescription = "Logo",
alignment = Alignment.TopStart,
modifier =
Modifier
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
}
AnimatedVisibility(
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopEnd),
) {
TimeDisplay()
}
AnimatedVisibility(
visible = showDebugInfo && controllerViewState.controlsVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
PlaybackDebugOverlay(
currentPlayback = currentPlayback,
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.background(AppColors.TransparentBlack50),
)
}
}
}
/**
* The view state of the overlay
*/
enum class OverlayViewState {
CONTROLLER,
CHAPTERS,
QUEUE,
}
/**
* A wrapper for the playback controls to show title and other information, plus the actual controls
*
* @see PlaybackControls
*/
@Composable
fun Controller(
title: String?,
playerControls: Player,
controllerViewState: ControllerViewState,
showClock: Boolean,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekProgress: (Long) -> Unit,
nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
val verticalOffset by animateDpAsState(
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
label = "TitleBumpOffset",
animationSpec =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Dp.VisibilityThreshold,
),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.offset(y = verticalOffset),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.End),
) {
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
var endTimeStr by remember { mutableStateOf("...") }
LaunchedEffect(playerControls) {
while (isActive) {
val remaining =
(playerControls.duration - playerControls.currentPosition)
.div(playerControls.playbackParameters.speed)
.toLong()
.milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = getTimeFormatter().format(endTime)
delay(1.seconds)
}
}
Text(
text = "Ends $endTimeStr",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(end = 32.dp),
)
}
}
// TODO need to move these up a level?
val moreFocusRequester = remember { FocusRequester() }
val captionFocusRequester = remember { FocusRequester() }
val settingsFocusRequester = remember { FocusRequester() }
PlaybackControls(
modifier = Modifier.fillMaxWidth(),
playerControls = playerControls,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
onSeekProgress = {
onSeekProgress(it)
},
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment,
onClickPlaybackDialogType = onClickPlaybackDialogType,
moreFocusRequester = moreFocusRequester,
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
)
}
}

View file

@ -76,6 +76,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay
import com.github.damontecres.wholphin.ui.playback.overlay.SkipIndicator
import com.github.damontecres.wholphin.ui.playback.overlay.SkipSegmentButton
import com.github.damontecres.wholphin.ui.playback.overlay.rememberSeekBarState
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
@ -167,6 +173,7 @@ fun PlaybackPageContent(
),
)
val currentSegment by viewModel.currentSegment.collectAsState()
val analyticsState by viewModel.analyticsState.collectAsState()
val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
@ -389,7 +396,7 @@ fun PlaybackPageContent(
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
playerControls = player,
player = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
@ -412,6 +419,7 @@ fun PlaybackPageContent(
},
currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
analyticsState = analyticsState,
)
val subtitleSettings =

View file

@ -1,122 +0,0 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.util.TrackSupport
/**
* Debug info about the current playback tracks
*/
@Composable
fun PlaybackTrackInfo(
trackSupport: List<TrackSupport>,
modifier: Modifier = Modifier,
) {
val selectedWeight = .5f
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
val textStyle =
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)
LazyColumn(
modifier = modifier,
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
val texts =
listOf(
"ID",
"Type",
"Codec",
"Supported",
"Labels",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
Text(
text = "Selected",
style = textStyle,
)
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
items(trackSupport) { track ->
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier.ifElse(
track.selected,
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
),
) {
val texts =
listOf(
track.id ?: "",
track.type.name,
track.codecs ?: "",
track.supported.name,
track.labels.joinToString(", "),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
if (track.selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(12.dp),
)
} else {
Text(
text = "-",
style = textStyle,
)
}
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
}
}

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import android.media.MediaCodecList
import android.os.Build
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.ui.text.intl.Locale
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -104,6 +107,7 @@ import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand
@ -117,6 +121,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.Date
import java.util.UUID
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -174,6 +179,7 @@ class PlaybackViewModel
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>()
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
val analyticsState = MutableStateFlow(AnalyticsState())
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
@ -353,7 +359,6 @@ class PlaybackViewModel
}
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
val item = BaseItem.from(base, api)
val played =
play(
@ -1343,6 +1348,35 @@ class PlaybackViewModel
}
}
private fun updateDecoder(
decoderName: String,
type: MediaType,
) {
viewModelScope.launchDefault {
val codecInfo =
MediaCodecList(MediaCodecList.ALL_CODECS)
.codecInfos
.firstOrNull { !it.isEncoder && it.name == decoderName }
val decoderString =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (codecInfo?.isHardwareAccelerated == true) {
"$decoderName (HW)"
} else {
decoderName
}
} else {
decoderName
}
currentPlayback.update {
when (type) {
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
else -> throw IllegalArgumentException("Unsupported type: $type")
}
}
}
}
override fun onVideoDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
@ -1350,7 +1384,7 @@ class PlaybackViewModel
initializationDurationMs: Long,
) {
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
currentPlayback.update { it?.copy(videoDecoder = decoderName) }
updateDecoder(decoderName, MediaType.VIDEO)
}
override fun onVideoDisabled(
@ -1369,7 +1403,7 @@ class PlaybackViewModel
decoderReuseEvaluation?.let { decoder ->
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}")
currentPlayback.update { it?.copy(videoDecoder = decoder.decoderName) }
updateDecoder(decoder.decoderName, MediaType.VIDEO)
}
}
}
@ -1381,7 +1415,7 @@ class PlaybackViewModel
initializationDurationMs: Long,
) {
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
currentPlayback.update { it?.copy(audioDecoder = decoderName) }
updateDecoder(decoderName, MediaType.AUDIO)
}
override fun onAudioInputFormatChanged(
@ -1392,7 +1426,7 @@ class PlaybackViewModel
decoderReuseEvaluation?.let { decoder ->
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}")
currentPlayback.update { it?.copy(audioDecoder = decoder.decoderName) }
updateDecoder(decoder.decoderName, MediaType.AUDIO)
}
}
}
@ -1451,6 +1485,37 @@ class PlaybackViewModel
override fun onIsPlayingChanged(isPlaying: Boolean) {
screensaverService.keepScreenOn(isPlaying)
}
override fun onBandwidthEstimate(
eventTime: AnalyticsListener.EventTime,
totalLoadTimeMs: Int,
totalBytesLoaded: Long,
bitrateEstimate: Long,
) {
Timber.v(
"onBandwidthEstimate: totalLoadTimeMs=%s, totalBytesLoaded=%s, bitrateEstimate=%s",
totalLoadTimeMs,
totalBytesLoaded,
bitrateEstimate,
)
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
analyticsState.update {
it.copy(
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
)
}
}
}
override fun onDroppedVideoFrames(
eventTime: AnalyticsListener.EventTime,
droppedFrames: Int,
elapsedMs: Long,
) {
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
}
}
data class PlayerState(
@ -1463,3 +1528,9 @@ data class MediaSegmentState(
val segment: MediaSegmentDto,
val interacted: Boolean,
)
data class AnalyticsState(
val bitrate: String = formatBitrate(0),
val bitrateEstimate: String = formatBitrate(0),
val droppedFrames: Int = 0,
)

View file

@ -0,0 +1,157 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun ChapterRowOverlay(
player: Player,
controllerViewState: ControllerViewState,
chapters: List<Chapter>,
playlist: Playlist,
aspectRatio: Float,
onChangeState: (OverlayViewState) -> Unit,
modifier: Modifier = Modifier,
) {
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val chapterIndex =
remember {
val position = player.currentPosition.milliseconds
val index =
chapters
.indexOfFirst { it.position > position }
.minus(1)
.let {
if (it < 0) {
// Didn't find a chapter, so it's either the first or last
if (position < chapters.first().position) {
0
} else {
chapters.lastIndex
}
} else {
it
}
}.coerceIn(0, chapters.lastIndex)
index
}
val listState = rememberLazyListState(chapterIndex)
val focusRequester = remember { FocusRequester() }
val hiddenFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
HiddenFocusBox(hiddenFocusRequester) {
onChangeState.invoke(OverlayViewState.CONTROLLER)
}
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
state = listState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
}.focusProperties {
up = hiddenFocusRequester
},
) {
itemsIndexed(chapters) { index, chapter ->
val interactionSource = chapterInteractionSources[index]
val isFocused =
interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
ChapterCard(
name = chapter.name,
position = chapter.position,
imageUrl = chapter.imageUrl,
aspectRatio = aspectRatio,
onClick = {
player.seekTo(chapter.position.inWholeMilliseconds)
controllerViewState.hideControls()
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
index == chapterIndex,
Modifier
.focusRequester(focusRequester)
.bringIntoViewRequester(
bringIntoViewRequester,
),
).ifElse(
index == 0,
Modifier.focusProperties {
// Prevent scrolling left on first card to prevent moving down
left = FocusRequester.Cancel
},
),
)
}
}
if (playlist.hasNext()) {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(bottom = 8.dp)
.onFocusChanged {
if (it.isFocused) onChangeState.invoke(OverlayViewState.QUEUE)
}.focusable(),
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility

View file

@ -0,0 +1,256 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.MediaSegmentDto
import java.time.LocalTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackController(
item: BaseItem?,
nextState: OverlayViewState?,
player: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit,
currentSegment: MediaSegmentDto?,
onChangeState: (OverlayViewState) -> Unit,
modifier: Modifier = Modifier,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Controller(
title = item?.title,
subtitle = item?.subtitleLong,
player = player,
controllerViewState = controllerViewState,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekProgress = onSeekBarChange,
seekBarInteractionSource = seekBarInteractionSource,
currentSegment = currentSegment,
modifier = Modifier,
)
when (nextState) {
OverlayViewState.CHAPTERS -> {
Text(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) onChangeState.invoke(nextState)
}.focusable(),
)
}
OverlayViewState.QUEUE -> {
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 0.dp)
.onFocusChanged {
if (it.isFocused) onChangeState.invoke(nextState)
}.focusable(),
)
}
else -> {
Spacer(Modifier.height(32.dp))
}
}
}
}
internal val titleTextSize = 28.sp
internal val subtitleTextSize = 18.sp
/**
* A wrapper for the playback controls to show title and other information, plus the actual controls
*
* @see PlaybackControls
*/
@Composable
fun Controller(
title: String?,
player: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekProgress: (Long) -> Unit,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
val verticalOffset by animateDpAsState(
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
label = "TitleBumpOffset",
animationSpec =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Dp.VisibilityThreshold,
),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.offset(y = verticalOffset),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.End),
) {
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
var endTimeStr by remember { mutableStateOf("...") }
LaunchedEffect(player) {
while (isActive) {
val remaining =
(player.duration - player.currentPosition)
.div(player.playbackParameters.speed)
.toLong()
.milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = getTimeFormatter().format(endTime)
delay(1.seconds)
}
}
Text(
text = "Ends $endTimeStr",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(end = 32.dp),
)
}
}
// TODO need to move these up a level?
val moreFocusRequester = remember { FocusRequester() }
val captionFocusRequester = remember { FocusRequester() }
val settingsFocusRequester = remember { FocusRequester() }
PlaybackControls(
modifier = Modifier.fillMaxWidth(),
player = player,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
onSeekProgress = {
onSeekProgress(it)
},
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment,
onClickPlaybackDialogType = onClickPlaybackDialogType,
moreFocusRequester = moreFocusRequester,
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
)
}
}

View file

@ -1,6 +1,6 @@
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import android.view.Gravity
import androidx.annotation.DrawableRes
@ -69,6 +69,8 @@ import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.skipStringRes
@ -123,7 +125,7 @@ sealed interface PlaybackAction {
@OptIn(UnstableApi::class)
@Composable
fun PlaybackControls(
playerControls: Player,
player: Player,
controllerViewState: ControllerViewState,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
@ -164,7 +166,7 @@ fun PlaybackControls(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
SeekBar(
player = playerControls,
player = player,
controllerViewState = controllerViewState,
onSeekProgress = onSeekProgress,
interactionSource = seekBarInteractionSource,
@ -190,7 +192,7 @@ fun PlaybackControls(
modifier = Modifier.align(Alignment.CenterStart),
)
PlaybackButtons(
player = playerControls,
player = player,
initialFocusRequester = initialFocusRequester,
onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick,
@ -209,7 +211,7 @@ fun PlaybackControls(
TextButton(
stringRes = segment.type.skipStringRes,
onClick = {
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
},
modifier =
Modifier

View file

@ -0,0 +1,298 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.TrackSupportReason
import com.github.damontecres.wholphin.util.TrackType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.HardwareAccelerationType
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaSourceType
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodeReason
import org.jellyfin.sdk.model.api.TranscodingInfo
import timber.log.Timber
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackDebugOverlay(
analyticsState: AnalyticsState,
currentPlayback: CurrentPlayback?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val display =
remember(context) {
try {
val displayManager =
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
} catch (ex: Exception) {
Timber.e(ex)
null
}
}
val displayMode by produceState<String?>(null) {
while (isActive) {
value =
display?.mode?.let {
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
}
delay(10.seconds)
}
}
val textStyle =
MaterialTheme.typography.bodySmall.copy(
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface,
)
val memoryUsed by produceState("") {
withContext(Dispatchers.Default) {
while (isActive) {
val runtime = Runtime.getRuntime()
val total = runtime.totalMemory()
val free = runtime.freeMemory()
val used = total - free
val totalMemory = formatBytes(total)
val usedMemory = formatBytes(used)
value = "$usedMemory / $totalMemory"
delay(2.seconds)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
) {
ProvideTextStyle(textStyle) {
SimpleTable(
remember(currentPlayback, displayMode) {
buildList {
add("Backend:" to currentPlayback?.backend?.toString())
add("Play method:" to currentPlayback?.playMethod?.serialName)
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
add("Video Decoder:" to currentPlayback.videoDecoder)
add("Audio Decoder:" to (currentPlayback.audioDecoder ?: "Non-ExoPlayer"))
}
add("Display Mode: " to displayMode)
}
},
modifier = Modifier.weight(1f, fill = false),
keyWidth = 80.dp,
)
SimpleTable(
rows =
listOf(
"Bitrate: " to analyticsState.bitrate,
"Bitrate (est): " to analyticsState.bitrateEstimate,
"Dropped frames: " to analyticsState.droppedFrames,
"Memory" to memoryUsed,
),
keyWidth = 80.dp,
modifier = Modifier.weight(1f, fill = false),
)
currentPlayback?.transcodeInfo?.let { info ->
SimpleTable(
listOf(
"Reason:" to info.transcodeReasons.joinToString(", "),
"HW Accel:" to info.hardwareAccelerationType?.toString(),
"Container:" to info.container,
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
),
modifier = Modifier.weight(1f, fill = false),
keyWidth = 64.dp,
)
SimpleTable(
listOf(
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
"Video Direct:" to info.isVideoDirect.toString(),
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
"Audio Direct:" to info.isAudioDirect.toString(),
),
modifier = Modifier.weight(1f, fill = false),
keyWidth = 64.dp,
)
}
}
}
currentPlayback?.tracks?.letNotEmpty {
PlaybackTrackInfo(
trackSupport = it,
textStyle = textStyle,
)
}
}
}
@Composable
fun SimpleTable(
rows: List<Pair<String, Any?>>,
modifier: Modifier = Modifier,
keyWidth: Dp = 100.dp,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
rows.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = it.first,
modifier = Modifier.width(keyWidth),
)
Text(
text = it.second.toString(),
modifier = Modifier,
)
}
}
}
}
@OptIn(UnstableApi::class)
@PreviewTvSpec
@Composable
fun PlaybackDebugOverlayPreview() {
val currentPlayback =
CurrentPlayback(
item =
BaseItem(
data =
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
),
),
backend = PlayerBackend.EXO_PLAYER,
playMethod = PlayMethod.TRANSCODE,
playSessionId = "123",
liveStreamId = "123",
videoDecoder = "video.decoder.name",
audioDecoder = null, // "audio.decoder.name",
mediaSourceInfo =
MediaSourceInfo(
protocol = MediaProtocol.HTTP,
type = MediaSourceType.DEFAULT,
isRemote = false,
readAtNativeFramerate = true,
ignoreDts = true,
ignoreIndex = true,
genPtsInput = false,
supportsTranscoding = true,
supportsDirectStream = true,
supportsDirectPlay = true,
isInfiniteStream = false,
requiresOpening = false,
requiresClosing = false,
requiresLooping = false,
supportsProbing = true,
transcodingSubProtocol = MediaStreamProtocol.HTTP,
hasSegments = false,
),
transcodeInfo =
TranscodingInfo(
videoCodec = "h264",
audioCodec = "aac",
container = "HLS",
width = 1080,
height = 1920,
isVideoDirect = false,
isAudioDirect = false,
transcodeReasons =
listOf(
TranscodeReason.VIDEO_PROFILE_NOT_SUPPORTED,
TranscodeReason.AUDIO_CHANNELS_NOT_SUPPORTED,
),
hardwareAccelerationType = HardwareAccelerationType.NONE,
),
tracks =
listOf(
TrackSupport(
id = "0",
type = TrackType.VIDEO,
supported = TrackSupportReason.EXCEEDS_CAPABILITIES,
selected = true,
labels = listOf(),
codecs = "avc1",
format = Format.Builder().build(),
),
TrackSupport(
id = "0",
type = TrackType.AUDIO,
supported = TrackSupportReason.HANDLED,
selected = true,
labels = listOf(),
codecs = "ac3",
format = Format.Builder().build(),
),
) +
List(10) {
TrackSupport(
id = "0",
type = TrackType.TEXT,
supported = TrackSupportReason.HANDLED,
selected = false,
labels = listOf(),
codecs = "srt",
format = Format.Builder().build(),
)
},
)
WholphinTheme {
PlaybackDebugOverlay(
analyticsState = AnalyticsState(),
currentPlayback = currentPlayback,
modifier = Modifier.fillMaxSize(),
)
}
}

View file

@ -0,0 +1,399 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* The overlay during playback showing controls, seek preview image, debug info, etc
*/
@Composable
fun PlaybackOverlay(
item: BaseItem?,
chapters: List<Chapter>,
player: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
showClock: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean,
currentPlayback: CurrentPlayback?,
currentSegment: MediaSegmentDto?,
analyticsState: AnalyticsState,
modifier: Modifier = Modifier,
trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
onClickPlaylist: (BaseItem) -> Unit = {},
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
// Will be used for preview/trick play images
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(player.currentPosition) }
var seekProgressPercent = (seekProgressMs.toDouble() / player.duration).toFloat()
val density = LocalDensity.current
val titleHeight =
remember(item?.title) {
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight =
remember(item?.subtitleLong) {
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
}
// This will be calculated after composition
var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember(controllerViewState.controlsVisible) {
mutableStateOf(if (controllerViewState.controlsVisible) OverlayViewState.CONTROLLER else OverlayViewState.HIDDEN)
}
val onChangeState = { newState: OverlayViewState ->
state = newState
}
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter,
) {
AnimatedVisibility(
visible = controllerViewState.controlsVisible && !showDebugInfo,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize(),
) {
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(scrimBrush),
)
}
AnimatedContent(
targetState = state,
label = "controls transition",
transitionSpec = {
if (targetState.ordinal > initialState.ordinal) {
// Moving down, so move content up
(slideInVertically { it / 2 } + fadeIn()).togetherWith(slideOutVertically { -it / 2 } + fadeOut())
} else {
// Moving up
(slideInVertically { -it / 2 } + fadeIn()).togetherWith(slideOutVertically { it / 2 } + fadeOut())
}
},
) { targetState ->
when (targetState) {
OverlayViewState.HIDDEN -> {
// Necessary so the bounds for animation are full width
Box(Modifier.fillMaxWidth())
}
OverlayViewState.CONTROLLER -> {
if (seekBarFocused) {
LaunchedEffect(Unit) {
seekProgressPercent =
(player.currentPosition.toFloat() / player.duration)
}
}
val nextState =
remember(chapters, playlist) {
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else if (playlist.hasNext()) {
OverlayViewState.QUEUE
} else {
null
}
}
PlaybackController(
item = item,
nextState = nextState,
player = player,
controllerViewState = controllerViewState,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekBarChange = {
onSeekBarChange(it)
seekProgressMs = it
},
currentSegment = currentSegment,
onChangeState = onChangeState,
modifier =
Modifier
.padding(bottom = 8.dp)
.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() }
},
seekBarInteractionSource = seekBarInteractionSource,
)
}
OverlayViewState.CHAPTERS -> {
if (chapters.isNotEmpty()) {
ChapterRowOverlay(
player = player,
controllerViewState = controllerViewState,
chapters = chapters,
playlist = playlist,
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
onChangeState = onChangeState,
modifier =
Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(),
)
}
}
OverlayViewState.QUEUE -> {
if (playlist.hasNext()) {
QueueRowOverlay(
playlist = playlist,
controllerViewState = controllerViewState,
nextState =
remember(chapters) {
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else {
OverlayViewState.CONTROLLER
}
},
onChangeState = onChangeState,
onClickPlaylist = onClickPlaylist,
modifier =
Modifier
.padding(8.dp)
.fillMaxWidth(),
)
}
}
}
}
// Trickplay
AnimatedVisibility(
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.fillMaxWidth(.95f),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
) {
if (trickplayInfo != null) {
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val imageUrl = remember(index) { trickplayUrlFor(index) }
if (imageUrl != null) {
SeekPreviewImage(
modifier = Modifier,
previewImageUrl = imageUrl,
seekProgressMs = seekProgressMs,
trickPlayInfo = trickplayInfo,
)
}
}
Text(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.background(
Color.Black.copy(alpha = 0.6f),
shape = RoundedCornerShape(4.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
// Top
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility(
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
AsyncImage(
model = logoImageUrl,
contentDescription = "Logo",
alignment = Alignment.TopStart,
modifier =
Modifier
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
}
AnimatedVisibility(
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier =
Modifier
.align(Alignment.TopEnd),
) {
TimeDisplay()
}
// Debug overlay
AnimatedVisibility(
visible = showDebugInfo && controllerViewState.controlsVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier =
Modifier
.align(Alignment.TopStart),
) {
val configuration = LocalConfiguration.current
val height =
remember(
configuration,
controllerHeight,
) { configuration.screenHeightDp.dp - controllerHeight }
PlaybackDebugOverlay(
analyticsState = analyticsState,
currentPlayback = currentPlayback,
modifier =
Modifier
.align(Alignment.TopStart)
.heightIn(max = height)
.padding(bottom = 16.dp)
.background(AppColors.TransparentBlack50)
.padding(8.dp)
.onFocusChanged {
if (it.hasFocus) {
// If Debug overlay gains focus, do not hide the controls
controllerViewState.pulseControls(Long.MAX_VALUE)
} else {
controllerViewState.pulseControls()
}
},
)
}
}
}
/**
* The view state of the overlay
*/
enum class OverlayViewState {
HIDDEN,
CONTROLLER,
CHAPTERS,
QUEUE,
}

View file

@ -0,0 +1,229 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.util.TrackSupport
/**
* Debug info about the current playback tracks
*/
@Composable
fun PlaybackTrackInfo(
trackSupport: List<TrackSupport>,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
) {
val selectedTracks = remember(trackSupport) { trackSupport.filter { it.selected } }
val selectedWeight = .5f
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
var expanded by remember { mutableStateOf(false) }
val focusRequesters =
remember(trackSupport.size) { List(trackSupport.size) { FocusRequester() } }
val showButtonFocusRequester = remember { FocusRequester() }
Column(
modifier = modifier,
verticalArrangement = Arrangement.SpaceBetween,
) {
LazyColumn(
modifier = Modifier.weight(1f, fill = false),
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
val texts =
listOf(
"ID",
"Type",
"Codec",
"Supported",
"Labels",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
Text(
text = "Selected",
style = textStyle,
)
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
val tracks = if (expanded) trackSupport else selectedTracks
itemsIndexed(tracks) { index, track ->
ProvideTextStyle(textStyle) {
TrackSupportRow(
track = track,
selectedWeight = selectedWeight,
weights = weights,
modifier =
Modifier
.focusRequester(focusRequesters[index])
.ifElse(
index == tracks.lastIndex,
Modifier.focusProperties {
down = showButtonFocusRequester
right = showButtonFocusRequester
},
),
)
}
}
}
if (trackSupport.size > selectedTracks.size) {
val density = LocalDensity.current
val height =
remember(density, textStyle) {
with(density) {
textStyle.fontSize.toDp() * 2
}
}
Box(Modifier.fillMaxWidth()) {
Button(
onClick = { expanded = !expanded },
shape =
ClickableSurfaceDefaults.shape(
shape = RoundedCornerShape(25),
),
contentHeight = height,
modifier =
Modifier
.align(Alignment.CenterEnd)
.focusRequester(showButtonFocusRequester)
.focusProperties {
up = focusRequesters[0]
},
) {
val text =
if (expanded) {
stringResource(R.string.hide)
} else {
stringResource(
R.string.show_more,
trackSupport.size - selectedTracks.size,
)
}
Text(
text = text,
fontSize = textStyle.fontSize,
)
}
}
}
}
}
@Composable
fun TrackSupportRow(
track: TrackSupport,
selectedWeight: Float,
weights: List<Float>,
modifier: Modifier = Modifier,
) {
Surface(
onClick = {},
modifier = modifier,
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
shape = ClickableSurfaceDefaults.shape(RectangleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = .25f),
),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier.ifElse(
track.selected,
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
),
) {
val texts =
listOf(
track.id ?: "",
track.type.name,
track.codecs ?: "",
track.supported.name,
track.labels.joinToString(", "),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
if (track.selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(12.dp),
)
} else {
Text(
text = "-",
)
}
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
)
}
}
}
}
}

View file

@ -0,0 +1,99 @@
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun QueueRowOverlay(
playlist: Playlist,
controllerViewState: ControllerViewState,
nextState: OverlayViewState,
onChangeState: (OverlayViewState) -> Unit,
onClickPlaylist: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
val items = remember { playlist.upcomingItems() }
val focusRequester = remember { FocusRequester() }
val hiddenFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
HiddenFocusBox(hiddenFocusRequester) {
onChangeState.invoke(nextState)
}
Text(
text = stringResource(R.string.queue),
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
contentPadding =
PaddingValues(
horizontal = 16.dp,
vertical = 8.dp,
),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(items) { index, item ->
val interactionSource =
remember { MutableInteractionSource() }
val isFocused =
interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
SeasonCard(
item = item,
onClick = {
onClickPlaylist.invoke(item)
controllerViewState.hideControls()
},
onLongClick = {},
imageHeight = 140.dp,
interactionSource = interactionSource,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
/*
* Modified from https://github.com/android/tv-samples
@ -46,6 +46,8 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.calculateSeekAccelerationMultiplier
import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.annotation.FloatRange
import androidx.compose.foundation.Canvas

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.playback
package com.github.damontecres.wholphin.ui.playback.overlay
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.InfiniteRepeatableSpec

View file

@ -35,7 +35,7 @@ import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.NavItem
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton
import com.github.damontecres.wholphin.ui.preferences.SliderPreference
import com.github.damontecres.wholphin.ui.preferences.SwitchPreference
import kotlinx.coroutines.flow.Flow

View file

@ -767,6 +767,7 @@
<string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string>
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string>
<string name="ass_subtitle_playback">SSA/ASS subtitle playback</string>
<string name="show_more">Show %1$s more</string>
<string name="skip_behavior">Skip behavior</string>
<string name="skip_behavior_summary">For intros, credits, etc</string>
<array name="ass_subtitle_modes">