mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Updates to the playback debug overlay (#1290)
## Description Lots of minor changes to the debug overlay - Adjusts the size and layout - Smaller font size - Only shows selected tracks by default - Expanding tracks are scrollable and don't cover controls - Show (estimated) network bitrate - Show heap memory usage - Better controls/chapters/queue animations - Minor optimizations Dev note: There's lots of code reorganization too ### Related issues Related to #1216 Related to https://github.com/damontecres/Wholphin/issues/528 ### Testing Emulator & nvidia shield ## Screenshots ### Selected tracks <img width="1280" height="720" alt="overlay1 Large" src="https://github.com/user-attachments/assets/e6ef71f5-73ef-4947-8b2a-0f41f64ef019" /> ### Expanded tracks, scrolled down <img width="1280" height="720" alt="overlay2 Large" src="https://github.com/user-attachments/assets/2e655f7e-7331-42dd-a572-0173b06563a8" /> ### Transcode info <img width="1280" height="720" alt="overlay3 Large" src="https://github.com/user-attachments/assets/3081e2ec-bb9e-422c-b833-c22785c649bf" /> ## AI or LLM usage None
This commit is contained in:
parent
0f9d458bc7
commit
50174db22e
26 changed files with 1589 additions and 1013 deletions
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun PlaybackDebugOverlay(
|
||||
currentPlayback: CurrentPlayback?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val display =
|
||||
remember(context) {
|
||||
try {
|
||||
val displayManager =
|
||||
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
|
||||
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
null
|
||||
}
|
||||
}
|
||||
val displayMode by produceState<String?>(null) {
|
||||
while (isActive) {
|
||||
value =
|
||||
display?.mode?.let {
|
||||
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
|
||||
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
|
||||
}
|
||||
delay(10.seconds)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(48.dp),
|
||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
|
||||
) {
|
||||
ProvideTextStyle(MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)) {
|
||||
SimpleTable(
|
||||
buildList {
|
||||
add("Backend:" to currentPlayback?.backend?.toString())
|
||||
add("Play method:" to currentPlayback?.playMethod?.serialName)
|
||||
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
|
||||
add("Video Decoder:" to currentPlayback.videoDecoder)
|
||||
add("Audio Decoder:" to currentPlayback.audioDecoder)
|
||||
}
|
||||
add("Display Mode: " to displayMode?.toString())
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
currentPlayback?.transcodeInfo?.let {
|
||||
TranscodeInfo(it, Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPlayback?.tracks?.letNotEmpty {
|
||||
PlaybackTrackInfo(
|
||||
trackSupport = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TranscodeInfo(
|
||||
info: TranscodingInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
SimpleTable(
|
||||
listOf(
|
||||
"Reason:" to info.transcodeReasons.joinToString(", "),
|
||||
"HW Accel:" to info.hardwareAccelerationType?.toString(),
|
||||
"Container:" to info.container,
|
||||
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
|
||||
),
|
||||
)
|
||||
SimpleTable(
|
||||
listOf(
|
||||
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
|
||||
"Video Direct:" to info.isVideoDirect.toString(),
|
||||
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
|
||||
"Audio Direct:" to info.isAudioDirect.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTable(
|
||||
rows: List<Pair<String, String?>>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
rows.forEach {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = it.first,
|
||||
modifier = Modifier.width(100.dp),
|
||||
)
|
||||
Text(
|
||||
text = it.second.toString(),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,705 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOut
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.cards.ChapterCard
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
import java.time.LocalTime
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val titleTextSize = 28.sp
|
||||
private val subtitleTextSize = 18.sp
|
||||
|
||||
/**
|
||||
* The overlay during playback showing controls, seek preview image, debug info, etc
|
||||
*/
|
||||
@Composable
|
||||
fun PlaybackOverlay(
|
||||
item: BaseItem?,
|
||||
chapters: List<Chapter>,
|
||||
playerControls: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
showPlay: Boolean,
|
||||
showClock: Boolean,
|
||||
previousEnabled: Boolean,
|
||||
nextEnabled: Boolean,
|
||||
seekEnabled: Boolean,
|
||||
seekBack: Duration,
|
||||
skipBackOnResume: Duration?,
|
||||
seekForward: Duration,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
onSeekBarChange: (Long) -> Unit,
|
||||
showDebugInfo: Boolean,
|
||||
currentPlayback: CurrentPlayback?,
|
||||
currentSegment: MediaSegmentDto?,
|
||||
modifier: Modifier = Modifier,
|
||||
trickplayInfo: TrickplayInfo? = null,
|
||||
trickplayUrlFor: (Int) -> String? = { null },
|
||||
playlist: Playlist = Playlist(listOf(), 0),
|
||||
onClickPlaylist: (BaseItem) -> Unit = {},
|
||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||
// Will be used for preview/trick play images
|
||||
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
|
||||
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
val titleHeight =
|
||||
remember(item?.title) {
|
||||
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
||||
}
|
||||
val subtitleHeight =
|
||||
remember(item?.subtitleLong) {
|
||||
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
||||
}
|
||||
|
||||
// This will be calculated after composition
|
||||
var controllerHeight by remember { mutableStateOf(0.dp) }
|
||||
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
// Background scrim for OSD readability
|
||||
val scrimBrush =
|
||||
remember {
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Black.copy(alpha = 0.80f),
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(scrimBrush),
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
|
||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
if (seekBarFocused) {
|
||||
LaunchedEffect(Unit) {
|
||||
seekProgressPercent =
|
||||
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
||||
}
|
||||
}
|
||||
val nextState =
|
||||
if (chapters.isNotEmpty()) {
|
||||
OverlayViewState.CHAPTERS
|
||||
} else if (playlist.hasNext()) {
|
||||
OverlayViewState.QUEUE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.onGloballyPositioned {
|
||||
controllerHeight = with(density) { it.size.height.toDp() }
|
||||
},
|
||||
) {
|
||||
Controller(
|
||||
title = item?.title,
|
||||
subtitle = item?.subtitleLong,
|
||||
playerControls = playerControls,
|
||||
controllerViewState = controllerViewState,
|
||||
showPlay = showPlay,
|
||||
showClock = showClock,
|
||||
previousEnabled = previousEnabled,
|
||||
nextEnabled = nextEnabled,
|
||||
seekEnabled = seekEnabled,
|
||||
seekBack = seekBack,
|
||||
skipBackOnResume = skipBackOnResume,
|
||||
seekForward = seekForward,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||
onSeekProgress = {
|
||||
onSeekBarChange(it)
|
||||
seekProgressMs = it
|
||||
},
|
||||
seekBarInteractionSource = seekBarInteractionSource,
|
||||
nextState = nextState,
|
||||
onNextStateFocus = {
|
||||
nextState?.let { state = it }
|
||||
},
|
||||
currentSegment = currentSegment,
|
||||
modifier =
|
||||
Modifier,
|
||||
// Don't use key events because this control has vertical items so up/down is tough to manage
|
||||
)
|
||||
when (nextState) {
|
||||
OverlayViewState.CHAPTERS -> {
|
||||
Text(
|
||||
text = stringResource(R.string.chapters),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp, top = 0.dp)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) state = nextState
|
||||
}.focusable(),
|
||||
)
|
||||
}
|
||||
|
||||
OverlayViewState.QUEUE -> {
|
||||
Text(
|
||||
text = stringResource(R.string.queue),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp, top = 0.dp)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) state = nextState
|
||||
}.focusable(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
|
||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
val chapterInteractionSources =
|
||||
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val chapterIndex =
|
||||
remember {
|
||||
val position = playerControls.currentPosition.milliseconds
|
||||
val index =
|
||||
chapters
|
||||
.indexOfFirst { it.position > position }
|
||||
.minus(1)
|
||||
.let {
|
||||
if (it < 0) {
|
||||
// Didn't find a chapter, so it's either the first or last
|
||||
if (position < chapters.first().position) {
|
||||
0
|
||||
} else {
|
||||
chapters.lastIndex
|
||||
}
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.coerceIn(0, chapters.lastIndex)
|
||||
index
|
||||
}
|
||||
val listState = rememberLazyListState(chapterIndex)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.onPreviewKeyEvent { e ->
|
||||
if (e.type == KeyEventType.KeyUp && isUp(e)) {
|
||||
state = OverlayViewState.CONTROLLER
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.chapters),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
LazyRow(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
},
|
||||
) {
|
||||
itemsIndexed(chapters) { index, chapter ->
|
||||
val interactionSource = chapterInteractionSources[index]
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
LaunchedEffect(isFocused) {
|
||||
if (isFocused) controllerViewState.pulseControls()
|
||||
}
|
||||
ChapterCard(
|
||||
name = chapter.name,
|
||||
position = chapter.position,
|
||||
imageUrl = chapter.imageUrl,
|
||||
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
|
||||
onClick = {
|
||||
playerControls.seekTo(chapter.position.inWholeMilliseconds)
|
||||
controllerViewState.hideControls()
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
index == chapterIndex,
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
).ifElse(
|
||||
index == 0,
|
||||
Modifier.focusProperties {
|
||||
// Prevent scrolling left on first card to prevent moving down
|
||||
left = FocusRequester.Cancel
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (playlist.hasNext()) {
|
||||
Text(
|
||||
text = stringResource(R.string.queue),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) state = OverlayViewState.QUEUE
|
||||
}.focusable(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
|
||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
if (playlist.hasNext()) {
|
||||
val items = remember { playlist.upcomingItems() }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth()
|
||||
.onPreviewKeyEvent { e ->
|
||||
if (e.type == KeyEventType.KeyUp && isUp(e)) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
state = OverlayViewState.CHAPTERS
|
||||
} else {
|
||||
state = OverlayViewState.CONTROLLER
|
||||
}
|
||||
true
|
||||
} else if (isDown(e)) {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.queue),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
},
|
||||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
LaunchedEffect(isFocused) {
|
||||
if (isFocused) controllerViewState.pulseControls()
|
||||
}
|
||||
SeasonCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
onClickPlaylist.invoke(item)
|
||||
controllerViewState.hideControls()
|
||||
},
|
||||
onLongClick = {},
|
||||
imageHeight = 140.dp,
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trickplay
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
|
||||
enter =
|
||||
expandVertically(
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
visibilityThreshold = IntSize.VisibilityThreshold,
|
||||
),
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(.95f),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.offsetByPercent(
|
||||
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
|
||||
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
|
||||
) {
|
||||
if (trickplayInfo != null) {
|
||||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||
val index =
|
||||
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
||||
val imageUrl = remember(index) { trickplayUrlFor(index) }
|
||||
|
||||
if (imageUrl != null) {
|
||||
SeekPreviewImage(
|
||||
modifier = Modifier,
|
||||
previewImageUrl = imageUrl,
|
||||
seekProgressMs = seekProgressMs,
|
||||
trickPlayInfo = trickplayInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = (seekProgressMs / 1000L).seconds.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top
|
||||
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
|
||||
AnimatedVisibility(
|
||||
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
|
||||
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
|
||||
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoImageUrl,
|
||||
contentDescription = "Logo",
|
||||
alignment = Alignment.TopStart,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 240.dp, height = 120.dp)
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
|
||||
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
|
||||
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd),
|
||||
) {
|
||||
TimeDisplay()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showDebugInfo && controllerViewState.controlsVisible,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart),
|
||||
) {
|
||||
PlaybackDebugOverlay(
|
||||
currentPlayback = currentPlayback,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The view state of the overlay
|
||||
*/
|
||||
enum class OverlayViewState {
|
||||
CONTROLLER,
|
||||
CHAPTERS,
|
||||
QUEUE,
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper for the playback controls to show title and other information, plus the actual controls
|
||||
*
|
||||
* @see PlaybackControls
|
||||
*/
|
||||
@Composable
|
||||
fun Controller(
|
||||
title: String?,
|
||||
playerControls: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
showClock: Boolean,
|
||||
showPlay: Boolean,
|
||||
previousEnabled: Boolean,
|
||||
nextEnabled: Boolean,
|
||||
seekEnabled: Boolean,
|
||||
seekBack: Duration,
|
||||
skipBackOnResume: Duration?,
|
||||
seekForward: Duration,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
onSeekProgress: (Long) -> Unit,
|
||||
nextState: OverlayViewState?,
|
||||
currentSegment: MediaSegmentDto?,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
onNextStateFocus: () -> Unit = {},
|
||||
) {
|
||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||
val verticalOffset by animateDpAsState(
|
||||
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
||||
label = "TitleBumpOffset",
|
||||
animationSpec =
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = Dp.VisibilityThreshold,
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.offset(y = verticalOffset),
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = titleTextSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.End),
|
||||
) {
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontSize = subtitleTextSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
}
|
||||
|
||||
var endTimeStr by remember { mutableStateOf("...") }
|
||||
LaunchedEffect(playerControls) {
|
||||
while (isActive) {
|
||||
val remaining =
|
||||
(playerControls.duration - playerControls.currentPosition)
|
||||
.div(playerControls.playbackParameters.speed)
|
||||
.toLong()
|
||||
.milliseconds
|
||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||
endTimeStr = getTimeFormatter().format(endTime)
|
||||
delay(1.seconds)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Ends $endTimeStr",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(end = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// TODO need to move these up a level?
|
||||
val moreFocusRequester = remember { FocusRequester() }
|
||||
val captionFocusRequester = remember { FocusRequester() }
|
||||
val settingsFocusRequester = remember { FocusRequester() }
|
||||
PlaybackControls(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
playerControls = playerControls,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
controllerViewState = controllerViewState,
|
||||
onSeekProgress = {
|
||||
onSeekProgress(it)
|
||||
},
|
||||
showPlay = showPlay,
|
||||
previousEnabled = previousEnabled,
|
||||
nextEnabled = nextEnabled,
|
||||
seekEnabled = seekEnabled,
|
||||
seekBarInteractionSource = seekBarInteractionSource,
|
||||
seekBarIntervals = 16,
|
||||
seekBack = seekBack,
|
||||
seekForward = seekForward,
|
||||
skipBackOnResume = skipBackOnResume,
|
||||
currentSegment = currentSegment,
|
||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||
moreFocusRequester = moreFocusRequester,
|
||||
captionFocusRequester = captionFocusRequester,
|
||||
settingsFocusRequester = settingsFocusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.util.TrackSupport
|
||||
|
||||
/**
|
||||
* Debug info about the current playback tracks
|
||||
*/
|
||||
@Composable
|
||||
fun PlaybackTrackInfo(
|
||||
trackSupport: List<TrackSupport>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val selectedWeight = .5f
|
||||
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
|
||||
val textStyle =
|
||||
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
val texts =
|
||||
listOf(
|
||||
"ID",
|
||||
"Type",
|
||||
"Codec",
|
||||
"Supported",
|
||||
"Labels",
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(selectedWeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Selected",
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
texts.forEachIndexed { index, text ->
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.weight(weights[index]),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(trackSupport) { track ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
track.selected,
|
||||
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
|
||||
),
|
||||
) {
|
||||
val texts =
|
||||
listOf(
|
||||
track.id ?: "",
|
||||
track.type.name,
|
||||
track.codecs ?: "",
|
||||
track.supported.name,
|
||||
track.labels.joinToString(", "),
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(selectedWeight),
|
||||
) {
|
||||
if (track.selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "-",
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
texts.forEachIndexed { index, text ->
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.weight(weights[index]),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
|
|
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
|||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -104,6 +107,7 @@ import org.jellyfin.sdk.model.api.ImageType
|
|||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||
|
|
@ -117,6 +121,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|||
import timber.log.Timber
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -174,6 +179,7 @@ class PlaybackViewModel
|
|||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||
val analyticsState = MutableStateFlow(AnalyticsState())
|
||||
|
||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||
|
||||
|
|
@ -353,7 +359,6 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
||||
|
||||
val item = BaseItem.from(base, api)
|
||||
val played =
|
||||
play(
|
||||
|
|
@ -1343,6 +1348,35 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateDecoder(
|
||||
decoderName: String,
|
||||
type: MediaType,
|
||||
) {
|
||||
viewModelScope.launchDefault {
|
||||
val codecInfo =
|
||||
MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
.codecInfos
|
||||
.firstOrNull { !it.isEncoder && it.name == decoderName }
|
||||
val decoderString =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (codecInfo?.isHardwareAccelerated == true) {
|
||||
"$decoderName (HW)"
|
||||
} else {
|
||||
decoderName
|
||||
}
|
||||
} else {
|
||||
decoderName
|
||||
}
|
||||
currentPlayback.update {
|
||||
when (type) {
|
||||
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
|
||||
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
|
||||
else -> throw IllegalArgumentException("Unsupported type: $type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVideoDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
|
|
@ -1350,7 +1384,7 @@ class PlaybackViewModel
|
|||
initializationDurationMs: Long,
|
||||
) {
|
||||
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
|
||||
currentPlayback.update { it?.copy(videoDecoder = decoderName) }
|
||||
updateDecoder(decoderName, MediaType.VIDEO)
|
||||
}
|
||||
|
||||
override fun onVideoDisabled(
|
||||
|
|
@ -1369,7 +1403,7 @@ class PlaybackViewModel
|
|||
decoderReuseEvaluation?.let { decoder ->
|
||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}")
|
||||
currentPlayback.update { it?.copy(videoDecoder = decoder.decoderName) }
|
||||
updateDecoder(decoder.decoderName, MediaType.VIDEO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1381,7 +1415,7 @@ class PlaybackViewModel
|
|||
initializationDurationMs: Long,
|
||||
) {
|
||||
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
|
||||
currentPlayback.update { it?.copy(audioDecoder = decoderName) }
|
||||
updateDecoder(decoderName, MediaType.AUDIO)
|
||||
}
|
||||
|
||||
override fun onAudioInputFormatChanged(
|
||||
|
|
@ -1392,7 +1426,7 @@ class PlaybackViewModel
|
|||
decoderReuseEvaluation?.let { decoder ->
|
||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}")
|
||||
currentPlayback.update { it?.copy(audioDecoder = decoder.decoderName) }
|
||||
updateDecoder(decoder.decoderName, MediaType.AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1451,6 +1485,37 @@ class PlaybackViewModel
|
|||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
screensaverService.keepScreenOn(isPlaying)
|
||||
}
|
||||
|
||||
override fun onBandwidthEstimate(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
totalLoadTimeMs: Int,
|
||||
totalBytesLoaded: Long,
|
||||
bitrateEstimate: Long,
|
||||
) {
|
||||
Timber.v(
|
||||
"onBandwidthEstimate: totalLoadTimeMs=%s, totalBytesLoaded=%s, bitrateEstimate=%s",
|
||||
totalLoadTimeMs,
|
||||
totalBytesLoaded,
|
||||
bitrateEstimate,
|
||||
)
|
||||
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
|
||||
analyticsState.update {
|
||||
it.copy(
|
||||
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
|
||||
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDroppedVideoFrames(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
droppedFrames: Int,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
|
||||
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
||||
}
|
||||
}
|
||||
|
||||
data class PlayerState(
|
||||
|
|
@ -1463,3 +1528,9 @@ data class MediaSegmentState(
|
|||
val segment: MediaSegmentDto,
|
||||
val interacted: Boolean,
|
||||
)
|
||||
|
||||
data class AnalyticsState(
|
||||
val bitrate: String = formatBitrate(0),
|
||||
val bitrateEstimate: String = formatBitrate(0),
|
||||
val droppedFrames: Int = 0,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.ui.cards.ChapterCard
|
||||
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun ChapterRowOverlay(
|
||||
player: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
chapters: List<Chapter>,
|
||||
playlist: Playlist,
|
||||
aspectRatio: Float,
|
||||
onChangeState: (OverlayViewState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val chapterInteractionSources =
|
||||
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val chapterIndex =
|
||||
remember {
|
||||
val position = player.currentPosition.milliseconds
|
||||
val index =
|
||||
chapters
|
||||
.indexOfFirst { it.position > position }
|
||||
.minus(1)
|
||||
.let {
|
||||
if (it < 0) {
|
||||
// Didn't find a chapter, so it's either the first or last
|
||||
if (position < chapters.first().position) {
|
||||
0
|
||||
} else {
|
||||
chapters.lastIndex
|
||||
}
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.coerceIn(0, chapters.lastIndex)
|
||||
index
|
||||
}
|
||||
val listState = rememberLazyListState(chapterIndex)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val hiddenFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
HiddenFocusBox(hiddenFocusRequester) {
|
||||
onChangeState.invoke(OverlayViewState.CONTROLLER)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.chapters),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
LazyRow(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
}.focusProperties {
|
||||
up = hiddenFocusRequester
|
||||
},
|
||||
) {
|
||||
itemsIndexed(chapters) { index, chapter ->
|
||||
val interactionSource = chapterInteractionSources[index]
|
||||
val isFocused =
|
||||
interactionSource.collectIsFocusedAsState().value
|
||||
LaunchedEffect(isFocused) {
|
||||
if (isFocused) controllerViewState.pulseControls()
|
||||
}
|
||||
ChapterCard(
|
||||
name = chapter.name,
|
||||
position = chapter.position,
|
||||
imageUrl = chapter.imageUrl,
|
||||
aspectRatio = aspectRatio,
|
||||
onClick = {
|
||||
player.seekTo(chapter.position.inWholeMilliseconds)
|
||||
controllerViewState.hideControls()
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
index == chapterIndex,
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.bringIntoViewRequester(
|
||||
bringIntoViewRequester,
|
||||
),
|
||||
).ifElse(
|
||||
index == 0,
|
||||
Modifier.focusProperties {
|
||||
// Prevent scrolling left on first card to prevent moving down
|
||||
left = FocusRequester.Cancel
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (playlist.hasNext()) {
|
||||
Text(
|
||||
text = stringResource(R.string.queue),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) onChangeState.invoke(OverlayViewState.QUEUE)
|
||||
}.focusable(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||
import com.github.damontecres.wholphin.ui.formatBytes
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
|
||||
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.util.TrackSupport
|
||||
import com.github.damontecres.wholphin.util.TrackSupportReason
|
||||
import com.github.damontecres.wholphin.util.TrackType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.HardwareAccelerationType
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaSourceType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.TranscodeReason
|
||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun PlaybackDebugOverlay(
|
||||
analyticsState: AnalyticsState,
|
||||
currentPlayback: CurrentPlayback?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val display =
|
||||
remember(context) {
|
||||
try {
|
||||
val displayManager =
|
||||
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
|
||||
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
null
|
||||
}
|
||||
}
|
||||
val displayMode by produceState<String?>(null) {
|
||||
while (isActive) {
|
||||
value =
|
||||
display?.mode?.let {
|
||||
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
|
||||
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
|
||||
}
|
||||
delay(10.seconds)
|
||||
}
|
||||
}
|
||||
val textStyle =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
val memoryUsed by produceState("") {
|
||||
withContext(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
val runtime = Runtime.getRuntime()
|
||||
val total = runtime.totalMemory()
|
||||
val free = runtime.freeMemory()
|
||||
val used = total - free
|
||||
val totalMemory = formatBytes(total)
|
||||
val usedMemory = formatBytes(used)
|
||||
value = "$usedMemory / $totalMemory"
|
||||
delay(2.seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
|
||||
) {
|
||||
ProvideTextStyle(textStyle) {
|
||||
SimpleTable(
|
||||
remember(currentPlayback, displayMode) {
|
||||
buildList {
|
||||
add("Backend:" to currentPlayback?.backend?.toString())
|
||||
add("Play method:" to currentPlayback?.playMethod?.serialName)
|
||||
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
|
||||
add("Video Decoder:" to currentPlayback.videoDecoder)
|
||||
add("Audio Decoder:" to (currentPlayback.audioDecoder ?: "Non-ExoPlayer"))
|
||||
}
|
||||
add("Display Mode: " to displayMode)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
keyWidth = 80.dp,
|
||||
)
|
||||
SimpleTable(
|
||||
rows =
|
||||
listOf(
|
||||
"Bitrate: " to analyticsState.bitrate,
|
||||
"Bitrate (est): " to analyticsState.bitrateEstimate,
|
||||
"Dropped frames: " to analyticsState.droppedFrames,
|
||||
"Memory" to memoryUsed,
|
||||
),
|
||||
keyWidth = 80.dp,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
currentPlayback?.transcodeInfo?.let { info ->
|
||||
SimpleTable(
|
||||
listOf(
|
||||
"Reason:" to info.transcodeReasons.joinToString(", "),
|
||||
"HW Accel:" to info.hardwareAccelerationType?.toString(),
|
||||
"Container:" to info.container,
|
||||
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
|
||||
),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
keyWidth = 64.dp,
|
||||
)
|
||||
SimpleTable(
|
||||
listOf(
|
||||
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
|
||||
"Video Direct:" to info.isVideoDirect.toString(),
|
||||
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
|
||||
"Audio Direct:" to info.isAudioDirect.toString(),
|
||||
),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
keyWidth = 64.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPlayback?.tracks?.letNotEmpty {
|
||||
PlaybackTrackInfo(
|
||||
trackSupport = it,
|
||||
textStyle = textStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTable(
|
||||
rows: List<Pair<String, Any?>>,
|
||||
modifier: Modifier = Modifier,
|
||||
keyWidth: Dp = 100.dp,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
rows.forEach {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = it.first,
|
||||
modifier = Modifier.width(keyWidth),
|
||||
)
|
||||
Text(
|
||||
text = it.second.toString(),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
fun PlaybackDebugOverlayPreview() {
|
||||
val currentPlayback =
|
||||
CurrentPlayback(
|
||||
item =
|
||||
BaseItem(
|
||||
data =
|
||||
BaseItemDto(
|
||||
id = UUID.randomUUID(),
|
||||
type = BaseItemKind.EPISODE,
|
||||
),
|
||||
),
|
||||
backend = PlayerBackend.EXO_PLAYER,
|
||||
playMethod = PlayMethod.TRANSCODE,
|
||||
playSessionId = "123",
|
||||
liveStreamId = "123",
|
||||
videoDecoder = "video.decoder.name",
|
||||
audioDecoder = null, // "audio.decoder.name",
|
||||
mediaSourceInfo =
|
||||
MediaSourceInfo(
|
||||
protocol = MediaProtocol.HTTP,
|
||||
type = MediaSourceType.DEFAULT,
|
||||
isRemote = false,
|
||||
readAtNativeFramerate = true,
|
||||
ignoreDts = true,
|
||||
ignoreIndex = true,
|
||||
genPtsInput = false,
|
||||
supportsTranscoding = true,
|
||||
supportsDirectStream = true,
|
||||
supportsDirectPlay = true,
|
||||
isInfiniteStream = false,
|
||||
requiresOpening = false,
|
||||
requiresClosing = false,
|
||||
requiresLooping = false,
|
||||
supportsProbing = true,
|
||||
transcodingSubProtocol = MediaStreamProtocol.HTTP,
|
||||
hasSegments = false,
|
||||
),
|
||||
transcodeInfo =
|
||||
TranscodingInfo(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
container = "HLS",
|
||||
width = 1080,
|
||||
height = 1920,
|
||||
isVideoDirect = false,
|
||||
isAudioDirect = false,
|
||||
transcodeReasons =
|
||||
listOf(
|
||||
TranscodeReason.VIDEO_PROFILE_NOT_SUPPORTED,
|
||||
TranscodeReason.AUDIO_CHANNELS_NOT_SUPPORTED,
|
||||
),
|
||||
hardwareAccelerationType = HardwareAccelerationType.NONE,
|
||||
),
|
||||
tracks =
|
||||
listOf(
|
||||
TrackSupport(
|
||||
id = "0",
|
||||
type = TrackType.VIDEO,
|
||||
supported = TrackSupportReason.EXCEEDS_CAPABILITIES,
|
||||
selected = true,
|
||||
labels = listOf(),
|
||||
codecs = "avc1",
|
||||
format = Format.Builder().build(),
|
||||
),
|
||||
TrackSupport(
|
||||
id = "0",
|
||||
type = TrackType.AUDIO,
|
||||
supported = TrackSupportReason.HANDLED,
|
||||
selected = true,
|
||||
labels = listOf(),
|
||||
codecs = "ac3",
|
||||
format = Format.Builder().build(),
|
||||
),
|
||||
) +
|
||||
List(10) {
|
||||
TrackSupport(
|
||||
id = "0",
|
||||
type = TrackType.TEXT,
|
||||
supported = TrackSupportReason.HANDLED,
|
||||
selected = false,
|
||||
labels = listOf(),
|
||||
codecs = "srt",
|
||||
format = Format.Builder().build(),
|
||||
)
|
||||
},
|
||||
)
|
||||
WholphinTheme {
|
||||
PlaybackDebugOverlay(
|
||||
analyticsState = AnalyticsState(),
|
||||
currentPlayback = currentPlayback,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOut
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The overlay during playback showing controls, seek preview image, debug info, etc
|
||||
*/
|
||||
@Composable
|
||||
fun PlaybackOverlay(
|
||||
item: BaseItem?,
|
||||
chapters: List<Chapter>,
|
||||
player: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
showPlay: Boolean,
|
||||
showClock: Boolean,
|
||||
previousEnabled: Boolean,
|
||||
nextEnabled: Boolean,
|
||||
seekEnabled: Boolean,
|
||||
seekBack: Duration,
|
||||
skipBackOnResume: Duration?,
|
||||
seekForward: Duration,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
onSeekBarChange: (Long) -> Unit,
|
||||
showDebugInfo: Boolean,
|
||||
currentPlayback: CurrentPlayback?,
|
||||
currentSegment: MediaSegmentDto?,
|
||||
analyticsState: AnalyticsState,
|
||||
modifier: Modifier = Modifier,
|
||||
trickplayInfo: TrickplayInfo? = null,
|
||||
trickplayUrlFor: (Int) -> String? = { null },
|
||||
playlist: Playlist = Playlist(listOf(), 0),
|
||||
onClickPlaylist: (BaseItem) -> Unit = {},
|
||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||
// Will be used for preview/trick play images
|
||||
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(player.currentPosition) }
|
||||
var seekProgressPercent = (seekProgressMs.toDouble() / player.duration).toFloat()
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
val titleHeight =
|
||||
remember(item?.title) {
|
||||
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
||||
}
|
||||
val subtitleHeight =
|
||||
remember(item?.subtitleLong) {
|
||||
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
||||
}
|
||||
|
||||
// This will be calculated after composition
|
||||
var controllerHeight by remember { mutableStateOf(0.dp) }
|
||||
var state by remember(controllerViewState.controlsVisible) {
|
||||
mutableStateOf(if (controllerViewState.controlsVisible) OverlayViewState.CONTROLLER else OverlayViewState.HIDDEN)
|
||||
}
|
||||
val onChangeState = { newState: OverlayViewState ->
|
||||
state = newState
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible && !showDebugInfo,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
// Background scrim for OSD readability
|
||||
val scrimBrush =
|
||||
remember {
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Black.copy(alpha = 0.80f),
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(scrimBrush),
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
label = "controls transition",
|
||||
transitionSpec = {
|
||||
if (targetState.ordinal > initialState.ordinal) {
|
||||
// Moving down, so move content up
|
||||
(slideInVertically { it / 2 } + fadeIn()).togetherWith(slideOutVertically { -it / 2 } + fadeOut())
|
||||
} else {
|
||||
// Moving up
|
||||
(slideInVertically { -it / 2 } + fadeIn()).togetherWith(slideOutVertically { it / 2 } + fadeOut())
|
||||
}
|
||||
},
|
||||
) { targetState ->
|
||||
when (targetState) {
|
||||
OverlayViewState.HIDDEN -> {
|
||||
// Necessary so the bounds for animation are full width
|
||||
Box(Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
OverlayViewState.CONTROLLER -> {
|
||||
if (seekBarFocused) {
|
||||
LaunchedEffect(Unit) {
|
||||
seekProgressPercent =
|
||||
(player.currentPosition.toFloat() / player.duration)
|
||||
}
|
||||
}
|
||||
val nextState =
|
||||
remember(chapters, playlist) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
OverlayViewState.CHAPTERS
|
||||
} else if (playlist.hasNext()) {
|
||||
OverlayViewState.QUEUE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
PlaybackController(
|
||||
item = item,
|
||||
nextState = nextState,
|
||||
player = player,
|
||||
controllerViewState = controllerViewState,
|
||||
showPlay = showPlay,
|
||||
previousEnabled = previousEnabled,
|
||||
nextEnabled = nextEnabled,
|
||||
seekEnabled = seekEnabled,
|
||||
seekBack = seekBack,
|
||||
skipBackOnResume = skipBackOnResume,
|
||||
seekForward = seekForward,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||
onSeekBarChange = {
|
||||
onSeekBarChange(it)
|
||||
seekProgressMs = it
|
||||
},
|
||||
currentSegment = currentSegment,
|
||||
onChangeState = onChangeState,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.onGloballyPositioned {
|
||||
controllerHeight = with(density) { it.size.height.toDp() }
|
||||
},
|
||||
seekBarInteractionSource = seekBarInteractionSource,
|
||||
)
|
||||
}
|
||||
|
||||
OverlayViewState.CHAPTERS -> {
|
||||
if (chapters.isNotEmpty()) {
|
||||
ChapterRowOverlay(
|
||||
player = player,
|
||||
controllerViewState = controllerViewState,
|
||||
chapters = chapters,
|
||||
playlist = playlist,
|
||||
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
|
||||
onChangeState = onChangeState,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OverlayViewState.QUEUE -> {
|
||||
if (playlist.hasNext()) {
|
||||
QueueRowOverlay(
|
||||
playlist = playlist,
|
||||
controllerViewState = controllerViewState,
|
||||
nextState =
|
||||
remember(chapters) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
OverlayViewState.CHAPTERS
|
||||
} else {
|
||||
OverlayViewState.CONTROLLER
|
||||
}
|
||||
},
|
||||
onChangeState = onChangeState,
|
||||
onClickPlaylist = onClickPlaylist,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trickplay
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
|
||||
enter =
|
||||
expandVertically(
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
visibilityThreshold = IntSize.VisibilityThreshold,
|
||||
),
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(.95f),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.offsetByPercent(
|
||||
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
|
||||
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
|
||||
) {
|
||||
if (trickplayInfo != null) {
|
||||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||
val index =
|
||||
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
||||
val imageUrl = remember(index) { trickplayUrlFor(index) }
|
||||
|
||||
if (imageUrl != null) {
|
||||
SeekPreviewImage(
|
||||
modifier = Modifier,
|
||||
previewImageUrl = imageUrl,
|
||||
seekProgressMs = seekProgressMs,
|
||||
trickPlayInfo = trickplayInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = (seekProgressMs / 1000L).seconds.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top
|
||||
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
|
||||
AnimatedVisibility(
|
||||
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
|
||||
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
|
||||
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoImageUrl,
|
||||
contentDescription = "Logo",
|
||||
alignment = Alignment.TopStart,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 240.dp, height = 120.dp)
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
|
||||
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
|
||||
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd),
|
||||
) {
|
||||
TimeDisplay()
|
||||
}
|
||||
|
||||
// Debug overlay
|
||||
AnimatedVisibility(
|
||||
visible = showDebugInfo && controllerViewState.controlsVisible,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart),
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val height =
|
||||
remember(
|
||||
configuration,
|
||||
controllerHeight,
|
||||
) { configuration.screenHeightDp.dp - controllerHeight }
|
||||
PlaybackDebugOverlay(
|
||||
analyticsState = analyticsState,
|
||||
currentPlayback = currentPlayback,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.heightIn(max = height)
|
||||
.padding(bottom = 16.dp)
|
||||
.background(AppColors.TransparentBlack50)
|
||||
.padding(8.dp)
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) {
|
||||
// If Debug overlay gains focus, do not hide the controls
|
||||
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||
} else {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The view state of the overlay
|
||||
*/
|
||||
enum class OverlayViewState {
|
||||
HIDDEN,
|
||||
CONTROLLER,
|
||||
CHAPTERS,
|
||||
QUEUE,
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.util.TrackSupport
|
||||
|
||||
/**
|
||||
* Debug info about the current playback tracks
|
||||
*/
|
||||
@Composable
|
||||
fun PlaybackTrackInfo(
|
||||
trackSupport: List<TrackSupport>,
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
) {
|
||||
val selectedTracks = remember(trackSupport) { trackSupport.filter { it.selected } }
|
||||
val selectedWeight = .5f
|
||||
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val focusRequesters =
|
||||
remember(trackSupport.size) { List(trackSupport.size) { FocusRequester() } }
|
||||
val showButtonFocusRequester = remember { FocusRequester() }
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
val texts =
|
||||
listOf(
|
||||
"ID",
|
||||
"Type",
|
||||
"Codec",
|
||||
"Supported",
|
||||
"Labels",
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(selectedWeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Selected",
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
texts.forEachIndexed { index, text ->
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.weight(weights[index]),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val tracks = if (expanded) trackSupport else selectedTracks
|
||||
itemsIndexed(tracks) { index, track ->
|
||||
ProvideTextStyle(textStyle) {
|
||||
TrackSupportRow(
|
||||
track = track,
|
||||
selectedWeight = selectedWeight,
|
||||
weights = weights,
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.ifElse(
|
||||
index == tracks.lastIndex,
|
||||
Modifier.focusProperties {
|
||||
down = showButtonFocusRequester
|
||||
right = showButtonFocusRequester
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trackSupport.size > selectedTracks.size) {
|
||||
val density = LocalDensity.current
|
||||
val height =
|
||||
remember(density, textStyle) {
|
||||
with(density) {
|
||||
textStyle.fontSize.toDp() * 2
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = { expanded = !expanded },
|
||||
shape =
|
||||
ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(25),
|
||||
),
|
||||
contentHeight = height,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.focusRequester(showButtonFocusRequester)
|
||||
.focusProperties {
|
||||
up = focusRequesters[0]
|
||||
},
|
||||
) {
|
||||
val text =
|
||||
if (expanded) {
|
||||
stringResource(R.string.hide)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.show_more,
|
||||
trackSupport.size - selectedTracks.size,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = textStyle.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrackSupportRow(
|
||||
track: TrackSupport,
|
||||
selectedWeight: Float,
|
||||
weights: List<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = {},
|
||||
modifier = modifier,
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
shape = ClickableSurfaceDefaults.shape(RectangleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = .25f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
track.selected,
|
||||
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
|
||||
),
|
||||
) {
|
||||
val texts =
|
||||
listOf(
|
||||
track.id ?: "",
|
||||
track.type.name,
|
||||
track.codecs ?: "",
|
||||
track.supported.name,
|
||||
track.labels.joinToString(", "),
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(selectedWeight),
|
||||
) {
|
||||
if (track.selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "-",
|
||||
)
|
||||
}
|
||||
}
|
||||
texts.forEachIndexed { index, text ->
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.weight(weights[index]),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -767,6 +767,7 @@
|
|||
<string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string>
|
||||
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string>
|
||||
<string name="ass_subtitle_playback">SSA/ASS subtitle playback</string>
|
||||
<string name="show_more">Show %1$s more</string>
|
||||
<string name="skip_behavior">Skip behavior</string>
|
||||
<string name="skip_behavior_summary">For intros, credits, etc</string>
|
||||
<array name="ass_subtitle_modes">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue