Handle more remote & controller buttons during playback (#269)

## Details
Adds support for a few more media remote buttons

Adds support for common controller buttons

### Dev notes
Lifts the playback dialogs to the top level allowing them to be called
from anywhere during playback

## Issues
Closes #260
This commit is contained in:
damontecres 2025-11-20 12:29:17 -05:00 committed by GitHub
parent d3a3a2093a
commit 1f1d3443e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 328 additions and 286 deletions

View file

@ -34,7 +34,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -20,7 +20,27 @@ fun isDirectionalDpad(event: KeyEvent): Boolean =
fun isDpad(event: KeyEvent): Boolean = event.key == Key.DirectionCenter || isDirectionalDpad(event)
fun isEnterKey(event: KeyEvent) = event.key == Key.DirectionCenter || event.key == Key.Enter || event.key == Key.NumPadEnter
fun isEnterKey(event: KeyEvent) =
event.key == Key.DirectionCenter || event.key == Key.Enter || event.key == Key.NumPadEnter ||
event.key == Key.ButtonSelect || event.key == Key.ButtonA
fun isBackKey(event: KeyEvent) = event.key == Key.Back || event.key == Key.ButtonB
fun isControllerMedia(event: KeyEvent) =
event.key == Key.ButtonR1 ||
event.key == Key.ButtonR2 ||
event.key == Key.ButtonL1 ||
event.key == Key.ButtonL2
fun isSkipBack(event: KeyEvent) =
event.key == Key.DirectionLeft ||
event.key == Key.ButtonL1 ||
event.key == Key.ButtonL2
fun isSkipForward(event: KeyEvent) =
event.key == Key.DirectionRight ||
event.key == Key.ButtonR1 ||
event.key == Key.ButtonR2
fun isMedia(event: KeyEvent): Boolean =
event.key == Key.MediaPlay ||
@ -31,7 +51,10 @@ fun isMedia(event: KeyEvent): Boolean =
event.key == Key.MediaRewind ||
event.key == Key.MediaSkipBackward ||
event.key == Key.MediaNext ||
event.key == Key.MediaPrevious
event.key == Key.MediaPrevious ||
event.key == Key.Captions ||
event.key == Key.MediaAudioTrack ||
event.key == Key.MediaStop
fun isBackwardButton(event: KeyEvent): Boolean =
event.key == Key.PageUp ||

View file

@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale
import com.github.damontecres.wholphin.preferences.PrefContentScale
val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
val playbackScaleOptions =
mapOf(
ContentScale.Fit to "Fit",

View file

@ -32,7 +32,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@ -60,9 +59,7 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.stringRes
@ -73,7 +70,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@ -110,22 +106,18 @@ sealed interface PlaybackAction {
@OptIn(UnstableApi::class)
@Composable
fun PlaybackControls(
subtitleStreams: List<SubtitleStream>,
playerControls: Player,
controllerViewState: ControllerViewState,
onPlaybackActionClick: (PlaybackAction) -> Unit,
showDebugInfo: Boolean,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
moreFocusRequester: FocusRequester,
captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onSeekProgress: (Long) -> Unit,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
moreButtonOptions: MoreButtonOptions,
subtitleIndex: Int?,
audioIndex: Int?,
audioStreams: List<AudioStream>,
playbackSpeed: Float,
scale: ContentScale,
seekBarIntervals: Int,
seekBack: Duration,
skipBackOnResume: Duration?,
@ -144,12 +136,6 @@ fun PlaybackControls(
}
controllerViewState.pulseControls()
}
val onControllerInteractionForDialog = {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
controllerViewState.pulseControls(Long.MAX_VALUE)
}
LaunchedEffect(controllerViewState.controlsVisible) {
if (controllerViewState.controlsVisible) {
initialFocusRequester.tryRequestFocus()
@ -181,10 +167,9 @@ fun PlaybackControls(
.fillMaxWidth(),
) {
LeftPlaybackButtons(
moreFocusRequester = moreFocusRequester,
onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick,
showDebugInfo = showDebugInfo,
moreButtonOptions = moreButtonOptions,
onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier.align(Alignment.CenterStart),
)
PlaybackButtons(
@ -219,15 +204,10 @@ fun PlaybackControls(
}
}
RightPlaybackButtons(
subtitleStreams = subtitleStreams,
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
onControllerInteraction = onControllerInteraction,
onControllerInteractionForDialog = onControllerInteractionForDialog,
onPlaybackActionClick = onPlaybackActionClick,
subtitleIndex = subtitleIndex,
audioStreams = audioStreams,
audioIndex = audioIndex,
playbackSpeed = playbackSpeed,
scale = scale,
onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier,
)
}
@ -307,14 +287,11 @@ private val buttonSpacing = 4.dp
@Composable
fun LeftPlaybackButtons(
moreFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
showDebugInfo: Boolean,
moreButtonOptions: MoreButtonOptions,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
modifier: Modifier = Modifier,
) {
var showMoreOptions by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
@ -324,58 +301,23 @@ fun LeftPlaybackButtons(
iconRes = R.drawable.baseline_more_vert_96,
onClick = {
onControllerInteraction.invoke()
showMoreOptions = true
onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE)
},
enabled = true,
onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(focusRequester),
)
}
if (showMoreOptions) {
val options =
buildList {
addAll(moreButtonOptions.options.keys)
add(stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info))
}
BottomDialog(
choices = options,
onDismissRequest = {
showMoreOptions = false
focusRequester.tryRequestFocus()
},
onSelectChoice = { index, choice ->
val action = moreButtonOptions.options[choice] ?: PlaybackAction.ShowDebug
onPlaybackActionClick.invoke(action)
},
gravity = Gravity.START,
modifier = Modifier.focusRequester(moreFocusRequester),
)
}
}
private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
@Composable
fun RightPlaybackButtons(
subtitleStreams: List<SubtitleStream>,
captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit,
onControllerInteractionForDialog: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
subtitleIndex: Int?,
audioStreams: List<AudioStream>,
audioIndex: Int?,
playbackSpeed: Float,
scale: ContentScale,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
modifier: Modifier = Modifier,
) {
var showCaptionDialog by remember { mutableStateOf(false) }
var showOptionsDialog by remember { mutableStateOf(false) }
var showAudioDialog by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) }
var showScaleDialog by remember { mutableStateOf(false) }
val captionFocusRequester = remember { FocusRequester() }
val settingsFocusRequester = remember { FocusRequester() }
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
@ -385,8 +327,8 @@ fun RightPlaybackButtons(
enabled = true,
iconRes = R.drawable.captions_svgrepo_com,
onClick = {
onControllerInteractionForDialog.invoke()
showCaptionDialog = true
onControllerInteraction.invoke()
onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS)
},
onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(captionFocusRequester),
@ -395,131 +337,14 @@ fun RightPlaybackButtons(
PlaybackButton(
iconRes = R.drawable.vector_settings,
onClick = {
onControllerInteractionForDialog.invoke()
showOptionsDialog = true
onControllerInteraction.invoke()
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
},
enabled = true,
onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(settingsFocusRequester),
)
}
val scope = rememberCoroutineScope()
if (showCaptionDialog) {
val options = subtitleStreams.map { it.displayName }
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
val currentChoice =
subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex } ?: subtitleStreams.size
BottomDialog(
choices =
options +
listOf(
stringResource(R.string.none),
stringResource(R.string.search_and_download),
),
currentChoice = currentChoice,
onDismissRequest = {
onControllerInteraction.invoke()
showCaptionDialog = false
scope.launch {
// TODO this is hacky, but playback changes force refocus and this is a workaround
delay(250L)
captionFocusRequester.tryRequestFocus()
}
},
onSelectChoice = { index, _ ->
if (index in subtitleStreams.indices) {
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index))
} else {
val idx = index - subtitleStreams.size
if (idx == 0) {
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED))
} else {
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
}
}
},
gravity = Gravity.END,
)
}
if (showOptionsDialog) {
val options =
listOf(
stringResource(R.string.audio),
stringResource(R.string.playback_speed),
stringResource(R.string.video_scale),
)
BottomDialog(
choices = options,
currentChoice = null,
onDismissRequest = {
onControllerInteraction.invoke()
showOptionsDialog = false
},
onSelectChoice = { index, _ ->
onControllerInteractionForDialog.invoke()
when (index) {
0 -> showAudioDialog = true
1 -> showSpeedDialog = true
2 -> showScaleDialog = true
}
},
gravity = Gravity.END,
)
}
if (showAudioDialog) {
BottomDialog(
choices = audioStreams.map { it.displayName },
currentChoice = audioStreams.indexOfFirstOrNull { it.index == audioIndex },
onDismissRequest = {
onControllerInteraction.invoke()
showAudioDialog = false
scope.launch {
delay(250L)
settingsFocusRequester.tryRequestFocus()
}
},
onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(audioStreams[index].index))
},
gravity = Gravity.END,
)
}
if (showSpeedDialog) {
BottomDialog(
choices = speedOptions,
currentChoice = speedOptions.indexOf(playbackSpeed.toString()),
onDismissRequest = {
onControllerInteraction.invoke()
showSpeedDialog = false
scope.launch {
delay(250L)
settingsFocusRequester.tryRequestFocus()
}
},
onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat()))
},
gravity = Gravity.END,
)
}
if (showScaleDialog) {
BottomDialog(
choices = playbackScaleOptions.values.toList(),
currentChoice = playbackScaleOptions.keys.toList().indexOf(scale),
onDismissRequest = {
onControllerInteraction.invoke()
showScaleDialog = false
scope.launch {
delay(250L)
settingsFocusRequester.tryRequestFocus()
}
},
onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index]))
},
gravity = Gravity.END,
)
}
}
@OptIn(UnstableApi::class)
@ -629,7 +454,7 @@ fun PlaybackButton(
}
@Composable
private fun BottomDialog(
fun BottomDialog(
choices: List<String>,
onDismissRequest: () -> Unit,
onSelectChoice: (Int, String) -> Unit,

View file

@ -0,0 +1,174 @@
package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
import androidx.compose.runtime.Composable
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import timber.log.Timber
enum class PlaybackDialogType {
MORE,
CAPTIONS,
SETTINGS,
AUDIO,
PLAYBACK_SPEED,
VIDEO_SCALE,
}
data class PlaybackSettings(
val showDebugInfo: Boolean,
val audioIndex: Int?,
val audioStreams: List<AudioStream>,
val subtitleIndex: Int?,
val subtitleStreams: List<SubtitleStream>,
val playbackSpeed: Float,
val contentScale: ContentScale,
)
@Composable
fun PlaybackDialog(
type: PlaybackDialogType,
settings: PlaybackSettings,
onDismissRequest: () -> Unit,
onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
) {
when (type) {
PlaybackDialogType.MORE -> {
val options =
buildList {
add(stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info))
}
BottomDialog(
choices = options,
onDismissRequest = {
onDismissRequest.invoke()
// focusRequester.tryRequestFocus()
},
onSelectChoice = { index, choice ->
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
},
gravity = Gravity.START,
)
}
PlaybackDialogType.CAPTIONS -> {
val subtitleStreams = settings.subtitleStreams
val options = subtitleStreams.map { it.displayName }
Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options")
val currentChoice =
subtitleStreams.indexOfFirstOrNull { it.index == settings.subtitleIndex } ?: subtitleStreams.size
BottomDialog(
choices =
options +
listOf(
stringResource(R.string.none),
stringResource(R.string.search_and_download),
),
currentChoice = currentChoice,
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
// scope.launch {
// // TODO this is hacky, but playback changes force refocus and this is a workaround
// delay(250L)
// captionFocusRequester.tryRequestFocus()
// }
},
onSelectChoice = { index, _ ->
if (index in subtitleStreams.indices) {
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index))
} else {
val idx = index - subtitleStreams.size
if (idx == 0) {
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED))
} else {
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
}
}
},
gravity = Gravity.END,
)
}
PlaybackDialogType.SETTINGS -> {
val options =
listOf(
stringResource(R.string.audio),
stringResource(R.string.playback_speed),
stringResource(R.string.video_scale),
)
BottomDialog(
choices = options,
currentChoice = null,
onDismissRequest = onDismissRequest,
onSelectChoice = { index, _ ->
when (index) {
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
}
},
gravity = Gravity.END,
)
}
PlaybackDialogType.AUDIO ->
BottomDialog(
choices = settings.audioStreams.map { it.displayName },
currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex },
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
},
onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index))
},
gravity = Gravity.END,
)
PlaybackDialogType.PLAYBACK_SPEED ->
BottomDialog(
choices = playbackSpeedOptions,
currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()),
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
},
onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat()))
},
gravity = Gravity.END,
)
PlaybackDialogType.VIDEO_SCALE ->
BottomDialog(
choices = playbackScaleOptions.values.toList(),
currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale),
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
// scope.launch {
// delay(250L)
// settingsFocusRequester.tryRequestFocus()
// }
},
onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index]))
},
gravity = Gravity.END,
)
}
}

View file

@ -25,6 +25,8 @@ class PlaybackKeyHandler(
private val skipBackOnResume: Duration?,
private val oneClickPause: Boolean,
private val onInteraction: () -> Unit,
private val onStop: () -> Unit,
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
) {
fun onKeyEvent(it: KeyEvent): Boolean {
if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
@ -34,12 +36,12 @@ class PlaybackKeyHandler(
result = false
} else if (it.type != KeyEventType.KeyUp) {
result = false
} else if (isDirectionalDpad(it) || isEnterKey(it)) {
} else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && it.key == Key.DirectionLeft) {
if (skipWithLeftRight && isSkipBack(it)) {
updateSkipIndicator(-seekBack.inWholeMilliseconds)
player.seekBack(seekBack)
} else if (skipWithLeftRight && it.key == Key.DirectionRight) {
} else if (skipWithLeftRight && isSkipForward(it)) {
player.seekForward(seekForward)
updateSkipIndicator(seekForward.inWholeMilliseconds)
} else if (oneClickPause && isEnterKey(it)) {
@ -94,11 +96,16 @@ class PlaybackKeyHandler(
Key.MediaNext -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext()
Key.MediaPrevious -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)) player.seekToPrevious()
Key.Captions -> onPlaybackDialogTypeClick.invoke(PlaybackDialogType.CAPTIONS)
Key.MediaAudioTrack -> onPlaybackDialogTypeClick.invoke(PlaybackDialogType.AUDIO)
Key.MediaStop -> onStop.invoke()
else -> result = false
}
} else if (it.key == Key.Enter && !controllerViewState.controlsVisible) {
} else if (isEnterKey(it) && !controllerViewState.controlsVisible) {
controllerViewState.showControls()
} else if (it.key == Key.Back && controllerViewState.controlsVisible) {
} else if (isBackKey(it) && controllerViewState.controlsVisible) {
// TODO change this to a BackHandler?
controllerViewState.hideControls()
} else {

View file

@ -37,7 +37,6 @@ import androidx.compose.ui.focus.onFocusChanged
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.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
@ -50,7 +49,6 @@ 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.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors
@ -79,7 +77,6 @@ private val subtitleTextSize = 18.sp
@Composable
fun PlaybackOverlay(
item: BaseItem?,
subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>,
playerControls: Player,
controllerViewState: ControllerViewState,
@ -92,14 +89,10 @@ fun PlaybackOverlay(
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean,
scale: ContentScale,
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
trickplayInfo: TrickplayInfo? = null,
@ -151,7 +144,6 @@ fun PlaybackOverlay(
Controller(
title = item?.title,
subtitle = item?.subtitleLong,
subtitleStreams = subtitleStreams,
playerControls = playerControls,
controllerViewState = controllerViewState,
showPlay = showPlay,
@ -163,17 +155,11 @@ fun PlaybackOverlay(
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekProgress = {
onSeekBarChange(it)
seekProgressMs = it
},
showDebugInfo = showDebugInfo,
scale = scale,
playbackSpeed = playbackSpeed,
moreButtonOptions = moreButtonOptions,
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams,
seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState,
onNextStateFocus = {
@ -442,7 +428,6 @@ enum class OverlayViewState {
@Composable
fun Controller(
title: String?,
subtitleStreams: List<SubtitleStream>,
playerControls: Player,
controllerViewState: ControllerViewState,
showClock: Boolean,
@ -454,14 +439,8 @@ fun Controller(
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekProgress: (Long) -> Unit,
showDebugInfo: Boolean,
scale: ContentScale,
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>,
nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
@ -520,13 +499,15 @@ fun Controller(
}
}
}
// TODO need to move these up a level?
val moreFocusRequester = remember { FocusRequester() }
val captionFocusRequester = remember { FocusRequester() }
val settingsFocusRequester = remember { FocusRequester() }
PlaybackControls(
modifier = Modifier.fillMaxWidth(),
subtitleStreams = subtitleStreams,
playerControls = playerControls,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
showDebugInfo = showDebugInfo,
onSeekProgress = {
onSeekProgress(it)
},
@ -535,17 +516,15 @@ fun Controller(
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
moreButtonOptions = moreButtonOptions,
subtitleIndex = currentItemPlayback.subtitleIndex,
audioIndex = currentItemPlayback.audioIndex,
audioStreams = audioStreams,
playbackSpeed = playbackSpeed,
scale = scale,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment,
onClickPlaybackDialogType = onClickPlaybackDialogType,
moreFocusRequester = moreFocusRequester,
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
)
when (nextState) {
OverlayViewState.CHAPTERS ->

View file

@ -142,6 +142,8 @@ fun PlaybackPage(
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect {
if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) {
@ -202,8 +204,58 @@ fun PlaybackPage(
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onInteraction = viewModel::reportInteraction,
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
onStop = {
player.stop()
viewModel.navigationManager.goBack()
},
onPlaybackDialogTypeClick = { playbackDialog = it },
)
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
when (it) {
is PlaybackAction.PlaybackSpeed -> {
playbackSpeed = it.value
}
is PlaybackAction.Scale -> {
contentScale = it.scale
}
PlaybackAction.ShowDebug -> {
showDebugInfo = !showDebugInfo
}
PlaybackAction.ShowPlaylist -> TODO()
PlaybackAction.ShowVideoFilterDialog -> TODO()
is PlaybackAction.ToggleAudio -> {
viewModel.changeAudioStream(it.index)
}
is PlaybackAction.ToggleCaptions -> {
viewModel.changeSubtitleStream(it.index)
}
PlaybackAction.SearchCaptions -> {
controllerViewState.hideControls()
viewModel.searchForSubtitles()
}
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playNextUp()
}
PlaybackAction.Previous -> {
val pos = player.currentPosition
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
viewModel.playPrevious()
} else {
player.seekToPrevious()
}
}
}
}
val showSegment =
!segmentCancelled && currentSegment != null &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
@ -320,59 +372,11 @@ fun PlaybackPage(
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onPlaybackActionClick = {
when (it) {
is PlaybackAction.PlaybackSpeed -> {
playbackSpeed = it.value
}
is PlaybackAction.Scale -> {
contentScale = it.scale
}
PlaybackAction.ShowDebug -> {
showDebugInfo = !showDebugInfo
}
PlaybackAction.ShowPlaylist -> TODO()
PlaybackAction.ShowVideoFilterDialog -> TODO()
is PlaybackAction.ToggleAudio -> {
viewModel.changeAudioStream(it.index)
}
is PlaybackAction.ToggleCaptions -> {
viewModel.changeSubtitleStream(it.index)
}
PlaybackAction.SearchCaptions -> {
controllerViewState.hideControls()
viewModel.searchForSubtitles()
}
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playNextUp()
}
PlaybackAction.Previous -> {
val pos = player.currentPosition
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
viewModel.playPrevious()
} else {
player.seekToPrevious()
}
}
}
},
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = { playbackDialog = it },
onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo,
scale = contentScale,
playbackSpeed = playbackSpeed,
moreButtonOptions = MoreButtonOptions(mapOf()),
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = mediaInfo?.audioStreams ?: listOf(),
subtitleStreams = mediaInfo?.subtitleStreams ?: listOf(),
chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
@ -524,6 +528,33 @@ fun PlaybackPage(
)
}
}
playbackDialog?.let { type ->
PlaybackDialog(
type = type,
settings =
PlaybackSettings(
showDebugInfo = showDebugInfo,
audioIndex = currentItemPlayback?.audioIndex,
audioStreams = mediaInfo?.audioStreams.orEmpty(),
subtitleIndex = currentItemPlayback?.subtitleIndex,
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
playbackSpeed = playbackSpeed,
contentScale = contentScale,
),
onDismissRequest = {
playbackDialog = null
if (controllerViewState.controlsVisible) {
controllerViewState.pulseControls()
}
},
onControllerInteraction = {
controllerViewState.pulseControls(Long.MAX_VALUE)
},
onClickPlaybackDialogType = { playbackDialog = it },
onPlaybackActionClick = onPlaybackActionClick,
)
}
}
}
}