From 50174db22e922c4cd3a2a68cb7833943f24e6328 Mon Sep 17 00:00:00 2001
From: Damontecres <154766448+damontecres@users.noreply.github.com>
Date: Fri, 24 Apr 2026 15:51:43 -0400
Subject: [PATCH] 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
### Expanded tracks, scrolled down
### Transcode info
## AI or LLM usage
None
---
.../wholphin/ui/components/HiddenFocusBox.kt | 33 +
.../ui/detail/collection/CollectionDetails.kt | 14 +-
.../ui/detail/music/NowPlayingButtons.kt | 13 +-
.../ui/detail/music/NowPlayingOverlay.kt | 2 +-
.../ui/detail/music/NowPlayingPage.kt | 4 +-
.../ui/playback/PlaybackDebugOverlay.kt | 144 ----
.../wholphin/ui/playback/PlaybackDialog.kt | 7 +-
.../wholphin/ui/playback/PlaybackOverlay.kt | 705 ------------------
.../wholphin/ui/playback/PlaybackPage.kt | 10 +-
.../wholphin/ui/playback/PlaybackTrackInfo.kt | 122 ---
.../wholphin/ui/playback/PlaybackViewModel.kt | 81 +-
.../ui/playback/overlay/ChapterRowOverlay.kt | 157 ++++
.../playback/{ => overlay}/PauseIndicator.kt | 2 +-
.../ui/playback/overlay/PlaybackController.kt | 256 +++++++
.../{ => overlay}/PlaybackControls.kt | 12 +-
.../playback/overlay/PlaybackDebugOverlay.kt | 298 ++++++++
.../ui/playback/overlay/PlaybackOverlay.kt | 399 ++++++++++
.../ui/playback/overlay/PlaybackTrackInfo.kt | 229 ++++++
.../ui/playback/overlay/QueueRowOverlay.kt | 99 +++
.../ui/playback/{ => overlay}/SeekBar.kt | 4 +-
.../ui/playback/{ => overlay}/SeekBarState.kt | 2 +-
.../{ => overlay}/SeekPreviewImage.kt | 2 +-
.../playback/{ => overlay}/SkipIndicator.kt | 2 +-
.../{ => overlay}/SkipSegmentButton.kt | 2 +-
.../wholphin/ui/theme/ThemePreview.kt | 2 +-
app/src/main/res/values/strings.xml | 1 +
26 files changed, 1589 insertions(+), 1013 deletions(-)
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt
delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt
delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt
rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/PauseIndicator.kt (98%)
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt
rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/PlaybackControls.kt (98%)
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt
rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SeekBar.kt (98%)
rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SeekBarState.kt (96%)
rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SeekPreviewImage.kt (98%)
rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SkipIndicator.kt (97%)
rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SkipSegmentButton.kt (98%)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt
new file mode 100644
index 00000000..d74707e5
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt
@@ -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(),
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt
index 7e0ca325..2f61fa4f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt
@@ -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,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt
index 44f08012..1d976ede 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt
index d76e560e..a340f66c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
index 1f253b82..d7da35ed 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt
deleted file mode 100644
index e756a9df..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt
+++ /dev/null
@@ -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(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>,
- 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,
- )
- }
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
index 1968fac3..8217a516 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
@@ -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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
deleted file mode 100644
index 2b0247e7..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
+++ /dev/null
@@ -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,
- 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,
- )
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index 334cd062..68dcf9c5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
@@ -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 =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt
deleted file mode 100644
index 4bd59347..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt
+++ /dev/null
@@ -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,
- 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,
- )
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
index 9bb0e999..551fdd64 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
@@ -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(null)
val currentItemPlayback = MutableLiveData()
val currentSegment = MutableStateFlow(null)
+ val analyticsState = MutableStateFlow(AnalyticsState())
val subtitleCues = MutableLiveData>(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,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt
new file mode 100644
index 00000000..2270e5f7
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt
@@ -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,
+ 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(),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt
similarity index 98%
rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt
rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt
index 2f812970..8414d5c5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt
new file mode 100644
index 00000000..04f3d80a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt
@@ -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,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
similarity index 98%
rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt
rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
index d43b6c27..ae037e38 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt
new file mode 100644
index 00000000..1420ffb6
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt
@@ -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(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>,
+ 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(),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt
new file mode 100644
index 00000000..96adc4df
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt
@@ -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,
+ 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,
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt
new file mode 100644
index 00000000..e48fbb97
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt
@@ -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,
+ 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,
+ 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,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt
new file mode 100644
index 00000000..47ee1c85
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt
@@ -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),
+ ),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt
similarity index 98%
rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt
rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt
index 37cb86f7..1ca67657 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt
similarity index 96%
rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt
rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt
index 3de5dca7..16e95d15 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt
similarity index 98%
rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt
rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt
index 4d8ee0e5..35600342 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt
similarity index 97%
rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt
rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt
index c2344f5f..3ac39bfa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt
similarity index 98%
rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt
rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt
index 4270bf48..f4a14e26 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt
index 9cae3698..70d30254 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt
@@ -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
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 92d82972..fa9364cb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -767,6 +767,7 @@
Direct play with ExoPlayer built-in
Burn in/transcode on server
SSA/ASS subtitle playback
+ Show %1$s more
Skip behavior
For intros, credits, etc