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