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.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
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.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
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
|
// This box exists so that there is something focusable above the item content
|
||||||
// allowing focus to move up to restore the collection's header
|
// allowing focus to move up to restore the collection's header
|
||||||
Box(
|
HiddenFocusBox {
|
||||||
modifier =
|
itemsContentHasFocus = false
|
||||||
Modifier
|
}
|
||||||
.fillMaxWidth()
|
|
||||||
.height(0.dp)
|
|
||||||
.onFocusChanged {
|
|
||||||
if (it.isFocused) itemsContentHasFocus = false
|
|
||||||
}.focusable(),
|
|
||||||
)
|
|
||||||
if (state.viewOptions.cardViewOptions.showDetails) {
|
if (state.viewOptions.cardViewOptions.showDetails) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.music
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -22,7 +20,6 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.rememberPreviousButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberRepeatButtonState
|
import androidx.media3.ui.compose.state.rememberRepeatButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberShuffleButtonState
|
import androidx.media3.ui.compose.state.rememberShuffleButtonState
|
||||||
import androidx.tv.material3.Border
|
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
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.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.components.Button
|
import com.github.damontecres.wholphin.ui.components.Button
|
||||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing
|
||||||
import com.github.damontecres.wholphin.ui.playback.buttonSpacing
|
|
||||||
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
|
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import kotlin.time.Duration.Companion.seconds
|
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.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
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.preferences.MoveButton
|
||||||
import com.github.damontecres.wholphin.ui.roundSeconds
|
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
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.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.findActivity
|
import com.github.damontecres.wholphin.ui.findActivity
|
||||||
import com.github.damontecres.wholphin.ui.nav.Backdrop
|
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.PlaybackKeyHandler
|
||||||
import com.github.damontecres.wholphin.ui.playback.isUp
|
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.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
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.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
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 com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -65,10 +68,10 @@ data class PlaybackSettings(
|
||||||
/**
|
/**
|
||||||
* Centralized UI component for displaying dialogs during playback
|
* 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.
|
* [PlaybackDialogType] determining which dialog is shown by this component.
|
||||||
*
|
*
|
||||||
* @see PlaybackAction
|
* @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackDialog(
|
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.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
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.applyToMpv
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
|
||||||
|
|
@ -167,6 +173,7 @@ fun PlaybackPageContent(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val currentSegment by viewModel.currentSegment.collectAsState()
|
val currentSegment by viewModel.currentSegment.collectAsState()
|
||||||
|
val analyticsState by viewModel.analyticsState.collectAsState()
|
||||||
|
|
||||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
@ -389,7 +396,7 @@ fun PlaybackPageContent(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Transparent),
|
.background(Color.Transparent),
|
||||||
item = currentPlayback?.item,
|
item = currentPlayback?.item,
|
||||||
playerControls = player,
|
player = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
showPlay = playPauseState.showPlay,
|
showPlay = playPauseState.showPlay,
|
||||||
previousEnabled = true,
|
previousEnabled = true,
|
||||||
|
|
@ -412,6 +419,7 @@ fun PlaybackPageContent(
|
||||||
},
|
},
|
||||||
currentSegment = currentSegment?.segment,
|
currentSegment = currentSegment?.segment,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
|
analyticsState = analyticsState,
|
||||||
)
|
)
|
||||||
|
|
||||||
val subtitleSettings =
|
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
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaCodecList
|
||||||
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.ui.text.intl.Locale
|
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.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
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.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
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.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
|
|
@ -117,6 +121,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -174,6 +179,7 @@ class PlaybackViewModel
|
||||||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||||
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||||
|
val analyticsState = MutableStateFlow(AnalyticsState())
|
||||||
|
|
||||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||||
|
|
||||||
|
|
@ -353,7 +359,6 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
||||||
|
|
||||||
val item = BaseItem.from(base, api)
|
val item = BaseItem.from(base, api)
|
||||||
val played =
|
val played =
|
||||||
play(
|
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(
|
override fun onVideoDecoderInitialized(
|
||||||
eventTime: AnalyticsListener.EventTime,
|
eventTime: AnalyticsListener.EventTime,
|
||||||
decoderName: String,
|
decoderName: String,
|
||||||
|
|
@ -1350,7 +1384,7 @@ class PlaybackViewModel
|
||||||
initializationDurationMs: Long,
|
initializationDurationMs: Long,
|
||||||
) {
|
) {
|
||||||
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
|
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
|
||||||
currentPlayback.update { it?.copy(videoDecoder = decoderName) }
|
updateDecoder(decoderName, MediaType.VIDEO)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoDisabled(
|
override fun onVideoDisabled(
|
||||||
|
|
@ -1369,7 +1403,7 @@ class PlaybackViewModel
|
||||||
decoderReuseEvaluation?.let { decoder ->
|
decoderReuseEvaluation?.let { decoder ->
|
||||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||||
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}")
|
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,
|
initializationDurationMs: Long,
|
||||||
) {
|
) {
|
||||||
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
|
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
|
||||||
currentPlayback.update { it?.copy(audioDecoder = decoderName) }
|
updateDecoder(decoderName, MediaType.AUDIO)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAudioInputFormatChanged(
|
override fun onAudioInputFormatChanged(
|
||||||
|
|
@ -1392,7 +1426,7 @@ class PlaybackViewModel
|
||||||
decoderReuseEvaluation?.let { decoder ->
|
decoderReuseEvaluation?.let { decoder ->
|
||||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||||
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}")
|
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) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
screensaverService.keepScreenOn(isPlaying)
|
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(
|
data class PlayerState(
|
||||||
|
|
@ -1463,3 +1528,9 @@ data class MediaSegmentState(
|
||||||
val segment: MediaSegmentDto,
|
val segment: MediaSegmentDto,
|
||||||
val interacted: Boolean,
|
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.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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)
|
@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 android.view.Gravity
|
||||||
import androidx.annotation.DrawableRes
|
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.SelectedLeadingContent
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
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.seekBack
|
||||||
import com.github.damontecres.wholphin.ui.seekForward
|
import com.github.damontecres.wholphin.ui.seekForward
|
||||||
import com.github.damontecres.wholphin.ui.skipStringRes
|
import com.github.damontecres.wholphin.ui.skipStringRes
|
||||||
|
|
@ -123,7 +125,7 @@ sealed interface PlaybackAction {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackControls(
|
fun PlaybackControls(
|
||||||
playerControls: Player,
|
player: Player,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
|
|
@ -164,7 +166,7 @@ fun PlaybackControls(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
) {
|
) {
|
||||||
SeekBar(
|
SeekBar(
|
||||||
player = playerControls,
|
player = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
onSeekProgress = onSeekProgress,
|
onSeekProgress = onSeekProgress,
|
||||||
interactionSource = seekBarInteractionSource,
|
interactionSource = seekBarInteractionSource,
|
||||||
|
|
@ -190,7 +192,7 @@ fun PlaybackControls(
|
||||||
modifier = Modifier.align(Alignment.CenterStart),
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
)
|
)
|
||||||
PlaybackButtons(
|
PlaybackButtons(
|
||||||
player = playerControls,
|
player = player,
|
||||||
initialFocusRequester = initialFocusRequester,
|
initialFocusRequester = initialFocusRequester,
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
|
@ -209,7 +211,7 @@ fun PlaybackControls(
|
||||||
TextButton(
|
TextButton(
|
||||||
stringRes = segment.type.skipStringRes,
|
stringRes = segment.type.skipStringRes,
|
||||||
onClick = {
|
onClick = {
|
||||||
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
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
|
* 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.input.key.type
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
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 kotlinx.coroutines.FlowPreview
|
||||||
import kotlin.time.Duration
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.annotation.FloatRange
|
||||||
import androidx.compose.foundation.Canvas
|
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.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
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.animateColor
|
||||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
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.cards.WatchedIcon
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavItem
|
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.SliderPreference
|
||||||
import com.github.damontecres.wholphin.ui.preferences.SwitchPreference
|
import com.github.damontecres.wholphin.ui.preferences.SwitchPreference
|
||||||
import kotlinx.coroutines.flow.Flow
|
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_exoplayer">Direct play with ExoPlayer built-in</string>
|
||||||
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</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="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">Skip behavior</string>
|
||||||
<string name="skip_behavior_summary">For intros, credits, etc</string>
|
<string name="skip_behavior_summary">For intros, credits, etc</string>
|
||||||
<array name="ass_subtitle_modes">
|
<array name="ass_subtitle_modes">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue