Add option for trickplay preview mode when D-Pad seeking (#1650)
## Description Adds a setting to allow the user to choose between the current D-Pad skip functionality, and a new simple trickplay overlay. The setting is added in Advanced, under the Playback section, called "D-Pad seek mode". The options are "Skip time" and "Trickplay previews". I left the default as "Skip time" since that's how it operates now, and nothing would change for users after this option is added unless they go and set it. When "Trickplay previews" is enabled, it shows an simple overlay when using the D-Pad to seek (without the full overlay open). The new simple overlay is the same implementation of the components in the full overlay: seekbar, trickplay images, current playhead time, remaining time. The components were pulled from the full overlay's inline UI code into their own reusable components, now used in both the full overlay and the simple overlay. #### Notes I could see there being an adjustment for the overlay hide delay. It's currently hard coded to 1.5s. This could be the default method, but it seems like preference is split as to whether people want this, or the minimalistic skipped seconds only version. ### Related issues This is directly in response to this issue, as well as my own desire for the feature :) https://github.com/damontecres/Wholphin/discussions/1084 It's also loosely related to this issue, in that it would solve it since it shows the playhead and remaining times. https://github.com/damontecres/Wholphin/discussions/1122 ### Testing Built and tested on an Amazon Fire TV Stick 4K Max. I wouldn't anticipate and compatibility or performance changes since it's a simple settings/UI change involving already-used UI components. ## Screenshots <img width="1920" height="1080" alt="wholphin-20260630-155708" src="https://github.com/user-attachments/assets/52046cc2-7413-4f66-89dd-355464fa0f91" /> <img width="1920" height="1080" alt="wholphin-20260630-155723" src="https://github.com/user-attachments/assets/57a4a271-9e44-4d2a-bea3-ce5c9cdc731a" /> <img width="1920" height="1080" alt="wholphin-20260630-153825" src="https://github.com/user-attachments/assets/2cf664ff-3db5-4a2e-8626-10219946669c" /> ## AI or LLM usage AI was used to generate the change, automate some testing, and provide information surrounding the change. I manually did some refactoring and cleanup to keep it clean and consistent with the rest of the code conventions. --------- Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
parent
d2bf5cd1e2
commit
cf3c00a1e1
12 changed files with 317 additions and 71 deletions
|
|
@ -661,6 +661,19 @@ sealed interface AppPreference<Pref, T> {
|
|||
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
|
||||
)
|
||||
|
||||
val DpadSeekModePref =
|
||||
AppChoicePreference<AppPreferences, DpadSeekMode>(
|
||||
title = R.string.d_pad_seek_mode_title,
|
||||
defaultValue = DpadSeekMode.SKIP_TIME,
|
||||
getter = { it.playbackPreferences.dpadSeekMode },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackPreferences { dpadSeekMode = value }
|
||||
},
|
||||
displayValues = R.array.dpad_seek_mode_options,
|
||||
indexToValue = { DpadSeekMode.forNumber(it) },
|
||||
valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 },
|
||||
)
|
||||
|
||||
val GlobalContentScale =
|
||||
AppChoicePreference<AppPreferences, PrefContentScale>(
|
||||
title = R.string.global_content_scale,
|
||||
|
|
@ -1226,6 +1239,7 @@ val advancedPreferences =
|
|||
AppPreference.CinemaMode,
|
||||
AppPreference.GlobalContentScale,
|
||||
AppPreference.SkipSegments,
|
||||
AppPreference.DpadSeekModePref,
|
||||
AppPreference.MaxBitrate,
|
||||
AppPreference.RefreshRateSwitching,
|
||||
AppPreference.ResolutionSwitching,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class AppPreferencesSerializer
|
|||
AppPreference.RefreshRateSwitching.defaultValue
|
||||
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||
cinemaMode = AppPreference.CinemaMode.defaultValue
|
||||
dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue
|
||||
|
||||
overrides =
|
||||
PlaybackOverrides
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ fun NowPlayingPage(
|
|||
// seekBack = preferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
controllerViewState = controllerViewState,
|
||||
updateSkipIndicator = {},
|
||||
clearSkipIndicator = {},
|
||||
skipBackOnResume = null,
|
||||
// skipBackOnResume = preferences.playbackPreferences.skipBackOnResume,
|
||||
onInteraction = viewModel::reportInteraction,
|
||||
|
|
@ -132,6 +133,7 @@ fun NowPlayingPage(
|
|||
},
|
||||
onPlaybackDialogTypeClick = { },
|
||||
getDurationMs = { player.duration },
|
||||
dpadSeekMode = preferences.playbackPreferences.dpadSeekMode,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.key
|
|||
import androidx.compose.ui.input.key.type
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.Util
|
||||
import com.github.damontecres.wholphin.preferences.DpadSeekMode
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.github.damontecres.wholphin.ui.seekForward
|
||||
import kotlin.time.Duration
|
||||
|
|
@ -24,11 +25,15 @@ class PlaybackKeyHandler(
|
|||
private val getDurationMs: () -> Long,
|
||||
private val controllerViewState: ControllerViewState,
|
||||
private val updateSkipIndicator: (Long) -> Unit,
|
||||
private val clearSkipIndicator: () -> Unit,
|
||||
private val skipBackOnResume: Duration?,
|
||||
private val oneClickPause: Boolean,
|
||||
private val onInteraction: () -> Unit,
|
||||
private val onStop: () -> Unit,
|
||||
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
|
||||
private val isDpadSeekVisible: () -> Boolean = { false },
|
||||
private val onDpadSeek: (Long) -> Unit = { },
|
||||
private val dpadSeekMode: DpadSeekMode,
|
||||
) {
|
||||
private var leftHandledByRepeat = false
|
||||
private var rightHandledByRepeat = false
|
||||
|
|
@ -49,20 +54,19 @@ class PlaybackKeyHandler(
|
|||
if (!controllerViewState.controlsVisible) {
|
||||
if (skipWithLeftRight && isSkipBack(it)) {
|
||||
if (isLtr) {
|
||||
updateSkipIndicator(-seekBack.inWholeMilliseconds)
|
||||
player.seekBack(seekBack)
|
||||
seekBy(-seekBack)
|
||||
} else {
|
||||
updateSkipIndicator(seekForward.inWholeMilliseconds)
|
||||
player.seekForward(seekForward)
|
||||
seekBy(seekForward)
|
||||
}
|
||||
} else if (skipWithLeftRight && isSkipForward(it)) {
|
||||
if (isLtr) {
|
||||
player.seekForward(seekForward)
|
||||
updateSkipIndicator(seekForward.inWholeMilliseconds)
|
||||
seekBy(seekForward)
|
||||
} else {
|
||||
player.seekBack(seekBack)
|
||||
updateSkipIndicator(seekBack.inWholeMilliseconds)
|
||||
seekBy(-seekBack)
|
||||
}
|
||||
} else if (isEnterKey(it) && isDpadSeekVisible()) {
|
||||
// If d-pad seek bar is visible, hide it
|
||||
clearSkipIndicator.invoke()
|
||||
} else if (oneClickPause && isEnterKey(it)) {
|
||||
val wasPlaying = player.isPlaying
|
||||
Util.handlePlayPauseButtonAction(player)
|
||||
|
|
@ -180,12 +184,24 @@ class PlaybackKeyHandler(
|
|||
) {
|
||||
if (isBack) {
|
||||
val skipDuration = seekBack * multiplier
|
||||
player.seekBack(skipDuration)
|
||||
updateSkipIndicator(-skipDuration.inWholeMilliseconds)
|
||||
seekBy(-skipDuration)
|
||||
} else {
|
||||
val skipDuration = seekForward * multiplier
|
||||
player.seekForward(skipDuration)
|
||||
updateSkipIndicator(skipDuration.inWholeMilliseconds)
|
||||
seekBy(skipDuration)
|
||||
}
|
||||
}
|
||||
|
||||
private fun seekBy(duration: Duration) {
|
||||
val durationMs = duration.inWholeMilliseconds
|
||||
if (dpadSeekMode == DpadSeekMode.TRICKPLAY) {
|
||||
onDpadSeek.invoke(durationMs)
|
||||
} else {
|
||||
if (duration < Duration.ZERO) {
|
||||
player.seekBack(-duration)
|
||||
} else {
|
||||
player.seekForward(duration)
|
||||
}
|
||||
updateSkipIndicator(durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -68,6 +70,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||
import com.github.damontecres.wholphin.preferences.DpadSeekMode
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||
|
|
@ -78,6 +81,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog
|
|||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.DpadSeekOverlay
|
||||
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
|
||||
|
|
@ -221,6 +225,23 @@ fun PlaybackPageContent(
|
|||
skipIndicatorDuration += delta
|
||||
skipPosition = player.currentPosition
|
||||
}
|
||||
val onDpadSeek: (Long) -> Unit = { deltaMs ->
|
||||
if (skipIndicatorDuration == 0L) {
|
||||
skipPosition = player.currentPosition
|
||||
}
|
||||
if ((skipIndicatorDuration > 0 && deltaMs < 0) ||
|
||||
(skipIndicatorDuration < 0 && deltaMs > 0)
|
||||
) {
|
||||
skipIndicatorDuration = 0L
|
||||
}
|
||||
skipIndicatorDuration += deltaMs
|
||||
skipPosition =
|
||||
(skipPosition + deltaMs).coerceIn(
|
||||
minimumValue = 0L,
|
||||
maximumValue = player.duration.coerceAtLeast(0L),
|
||||
)
|
||||
seekBarState.onValueChange(skipPosition)
|
||||
}
|
||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||
val keyHandler =
|
||||
remember(isLtr, preferences) {
|
||||
|
|
@ -234,6 +255,7 @@ fun PlaybackPageContent(
|
|||
getDurationMs = { player.duration.coerceAtLeast(0L) },
|
||||
controllerViewState = controllerViewState,
|
||||
updateSkipIndicator = updateSkipIndicator,
|
||||
clearSkipIndicator = { skipIndicatorDuration = 0 },
|
||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||
onInteraction = viewModel::reportInteraction,
|
||||
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
|
||||
|
|
@ -242,6 +264,11 @@ fun PlaybackPageContent(
|
|||
viewModel.navigationManager.goBack()
|
||||
},
|
||||
onPlaybackDialogTypeClick = { playbackDialog = it },
|
||||
isDpadSeekVisible = {
|
||||
prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY && skipIndicatorDuration != 0L
|
||||
},
|
||||
onDpadSeek = onDpadSeek,
|
||||
dpadSeekMode = prefs.dpadSeekMode,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -351,8 +378,38 @@ fun PlaybackPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible =
|
||||
!controllerViewState.controlsVisible &&
|
||||
skipIndicatorDuration != 0L &&
|
||||
prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY,
|
||||
enter = fadeIn() + slideInVertically { it / 2 },
|
||||
exit = fadeOut() + slideOutVertically { it },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter),
|
||||
) {
|
||||
DpadSeekOverlay(
|
||||
player = player,
|
||||
seekPositionMs = skipPosition,
|
||||
trickplayInfo = state.currentMediaInfo.trickPlayInfo,
|
||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp)
|
||||
.fillMaxWidth(.95f),
|
||||
)
|
||||
// Clear the overlay after a delay
|
||||
LaunchedEffect(skipIndicatorDuration) {
|
||||
delay(1.5.seconds)
|
||||
skipIndicatorDuration = 0L
|
||||
}
|
||||
}
|
||||
|
||||
// If D-pad skipping, show the amount skipped in an animation
|
||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L && prefs.dpadSeekMode != DpadSeekMode.TRICKPLAY) {
|
||||
// Skip time mode: show seek distance indicator
|
||||
SkipIndicator(
|
||||
durationMs = skipIndicatorDuration,
|
||||
onFinish = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
|
||||
/**
|
||||
* The compact overlay shown during D-Pad seeking in trickplay mode.
|
||||
*/
|
||||
@Composable
|
||||
fun DpadSeekOverlay(
|
||||
player: Player,
|
||||
seekPositionMs: Long,
|
||||
trickplayInfo: TrickplayInfo?,
|
||||
trickplayUrlFor: (Int) -> String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val durationMs = player.duration.coerceAtLeast(0L)
|
||||
val seekProgressPercent =
|
||||
if (durationMs > 0) {
|
||||
(seekPositionMs.toDouble() / durationMs).toFloat().coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val bufferedProgress =
|
||||
if (durationMs > 0) {
|
||||
(player.bufferedPosition.toDouble() / durationMs).toFloat().coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TrickplayPreview(
|
||||
seekProgressMs = seekPositionMs,
|
||||
trickplayInfo = trickplayInfo,
|
||||
trickplayUrlFor = trickplayUrlFor,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.offsetByPercent(xPercentage = seekProgressPercent)
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
StaticSeekBarImpl(
|
||||
progress = seekProgressPercent,
|
||||
bufferedProgress = bufferedProgress,
|
||||
durationMs = durationMs,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SeekTimecodes(
|
||||
positionMs = seekPositionMs,
|
||||
durationMs = durationMs,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -272,32 +272,45 @@ fun SeekBar(
|
|||
seekBack = seekBack,
|
||||
seekForward = seekForward,
|
||||
)
|
||||
Row(
|
||||
SeekTimecodes(
|
||||
positionMs = position,
|
||||
durationMs = player.duration,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
val positionSec = position / 1000
|
||||
val remainingSec = (player.duration - position) / 1000
|
||||
val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) }
|
||||
val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" }
|
||||
Text(
|
||||
text = positionText,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp),
|
||||
)
|
||||
Text(
|
||||
text = remainingText,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SeekTimecodes(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
val positionSec = positionMs / 1000
|
||||
val remainingSec = (durationMs - positionMs) / 1000
|
||||
val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) }
|
||||
val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" }
|
||||
Text(
|
||||
text = positionText,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp),
|
||||
)
|
||||
Text(
|
||||
text = remainingText,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ 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
|
||||
|
|
@ -41,13 +40,10 @@ 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.platform.LocalResources
|
||||
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
|
||||
|
|
@ -55,7 +51,6 @@ import com.github.damontecres.wholphin.data.model.PlaylistItem
|
|||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||
import com.github.damontecres.wholphin.ui.formatDuration
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
|
||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||
|
|
@ -65,7 +60,6 @@ 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
|
||||
|
|
@ -288,33 +282,10 @@ fun PlaybackOverlay(
|
|||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
val resources = LocalResources.current
|
||||
val seekText = remember(seekProgressMs / 1000L) { resources.formatDuration((seekProgressMs / 1000L).seconds) }
|
||||
Text(
|
||||
text = seekText,
|
||||
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),
|
||||
TrickplayPreview(
|
||||
seekProgressMs = seekProgressMs,
|
||||
trickplayInfo = trickplayInfo,
|
||||
trickplayUrlFor = trickplayUrlFor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,30 @@ fun SteppedSeekBarImpl(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only seek bar for showing progress without taking focus or handling D-Pad input.
|
||||
*
|
||||
* This is used by overlays where seeking is handled elsewhere and the bar is only a visual indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun StaticSeekBarImpl(
|
||||
progress: Float,
|
||||
durationMs: Long,
|
||||
bufferedProgress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SeekBarDisplay(
|
||||
enabled = false,
|
||||
progress = progress,
|
||||
bufferedProgress = bufferedProgress,
|
||||
durationMs = durationMs,
|
||||
onLeft = {},
|
||||
onRight = {},
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.formatDuration
|
||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun TrickplayPreview(
|
||||
seekProgressMs: Long,
|
||||
trickplayInfo: TrickplayInfo?,
|
||||
trickplayUrlFor: (Int) -> String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
val resources = LocalResources.current
|
||||
val seekProgressSec = seekProgressMs / 1000L
|
||||
val seekText = remember(seekProgressSec) { resources.formatDuration(seekProgressSec.seconds) }
|
||||
Text(
|
||||
text = seekText,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,12 @@ message PlaybackPreferences {
|
|||
bool resolution_switching = 23;
|
||||
string external_player = 24;
|
||||
bool cinema_mode = 25;
|
||||
DpadSeekMode dpad_seek_mode = 26;
|
||||
}
|
||||
|
||||
enum DpadSeekMode {
|
||||
SKIP_TIME = 0;
|
||||
TRICKPLAY = 1;
|
||||
}
|
||||
|
||||
message HomePagePreferences{
|
||||
|
|
|
|||
|
|
@ -180,6 +180,9 @@
|
|||
<string name="add_to_playlist">Add to playlist</string>
|
||||
<string name="one_click_pause">Pause with one click</string>
|
||||
<string name="one_click_pause_summary_on">Press D-Pad center to pause/play</string>
|
||||
<string name="d_pad_seek_mode_title">D-Pad seek mode</string>
|
||||
<string name="dpad_seek_mode_skip_time">Skip time</string>
|
||||
<string name="dpad_seek_mode_trickplay">Trickplay previews</string>
|
||||
<string name="italic_font">Italicize font</string>
|
||||
<string name="font">Font</string>
|
||||
<string name="background">Background</string>
|
||||
|
|
@ -847,6 +850,11 @@
|
|||
<string name="confirm_enable_experimental_title">Enable experimental settings?</string>
|
||||
<string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string>
|
||||
<string name="video_tunneling">Video tunneling</string>
|
||||
<string-array name="dpad_seek_mode_options">
|
||||
<item>@string/dpad_seek_mode_skip_time</item>
|
||||
<item>@string/dpad_seek_mode_trickplay</item>
|
||||
</string-array>
|
||||
|
||||
<string name="blocklisted">Blocklisted</string>
|
||||
<string name="unavailable">Unavailable</string>
|
||||
<string name="subtitle_mode">Subtitle mode</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue