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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"> android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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 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 = fun isMedia(event: KeyEvent): Boolean =
event.key == Key.MediaPlay || event.key == Key.MediaPlay ||
@ -31,7 +51,10 @@ fun isMedia(event: KeyEvent): Boolean =
event.key == Key.MediaRewind || event.key == Key.MediaRewind ||
event.key == Key.MediaSkipBackward || event.key == Key.MediaSkipBackward ||
event.key == Key.MediaNext || 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 = fun isBackwardButton(event: KeyEvent): Boolean =
event.key == Key.PageUp || event.key == Key.PageUp ||

View file

@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.github.damontecres.wholphin.preferences.PrefContentScale 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 = val playbackScaleOptions =
mapOf( mapOf(
ContentScale.Fit to "Fit", ContentScale.Fit to "Fit",

View file

@ -32,7 +32,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -60,9 +59,7 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R 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.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
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.stringRes import com.github.damontecres.wholphin.ui.stringRes
@ -73,7 +70,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -110,22 +106,18 @@ sealed interface PlaybackAction {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
fun PlaybackControls( fun PlaybackControls(
subtitleStreams: List<SubtitleStream>,
playerControls: Player, playerControls: Player,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
showDebugInfo: Boolean, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
moreFocusRequester: FocusRequester,
captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onSeekProgress: (Long) -> Unit, onSeekProgress: (Long) -> Unit,
showPlay: Boolean, showPlay: Boolean,
previousEnabled: Boolean, previousEnabled: Boolean,
nextEnabled: Boolean, nextEnabled: Boolean,
seekEnabled: Boolean, seekEnabled: Boolean,
moreButtonOptions: MoreButtonOptions,
subtitleIndex: Int?,
audioIndex: Int?,
audioStreams: List<AudioStream>,
playbackSpeed: Float,
scale: ContentScale,
seekBarIntervals: Int, seekBarIntervals: Int,
seekBack: Duration, seekBack: Duration,
skipBackOnResume: Duration?, skipBackOnResume: Duration?,
@ -144,12 +136,6 @@ fun PlaybackControls(
} }
controllerViewState.pulseControls() controllerViewState.pulseControls()
} }
val onControllerInteractionForDialog = {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
controllerViewState.pulseControls(Long.MAX_VALUE)
}
LaunchedEffect(controllerViewState.controlsVisible) { LaunchedEffect(controllerViewState.controlsVisible) {
if (controllerViewState.controlsVisible) { if (controllerViewState.controlsVisible) {
initialFocusRequester.tryRequestFocus() initialFocusRequester.tryRequestFocus()
@ -181,10 +167,9 @@ fun PlaybackControls(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
LeftPlaybackButtons( LeftPlaybackButtons(
moreFocusRequester = moreFocusRequester,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick, onClickPlaybackDialogType = onClickPlaybackDialogType,
showDebugInfo = showDebugInfo,
moreButtonOptions = moreButtonOptions,
modifier = Modifier.align(Alignment.CenterStart), modifier = Modifier.align(Alignment.CenterStart),
) )
PlaybackButtons( PlaybackButtons(
@ -219,15 +204,10 @@ fun PlaybackControls(
} }
} }
RightPlaybackButtons( RightPlaybackButtons(
subtitleStreams = subtitleStreams, captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
onControllerInteractionForDialog = onControllerInteractionForDialog, onClickPlaybackDialogType = onClickPlaybackDialogType,
onPlaybackActionClick = onPlaybackActionClick,
subtitleIndex = subtitleIndex,
audioStreams = audioStreams,
audioIndex = audioIndex,
playbackSpeed = playbackSpeed,
scale = scale,
modifier = Modifier, modifier = Modifier,
) )
} }
@ -307,14 +287,11 @@ private val buttonSpacing = 4.dp
@Composable @Composable
fun LeftPlaybackButtons( fun LeftPlaybackButtons(
moreFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit, onControllerInteraction: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
showDebugInfo: Boolean,
moreButtonOptions: MoreButtonOptions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showMoreOptions by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
Row( Row(
modifier = modifier.focusGroup(), modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing), horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
@ -324,58 +301,23 @@ fun LeftPlaybackButtons(
iconRes = R.drawable.baseline_more_vert_96, iconRes = R.drawable.baseline_more_vert_96,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
showMoreOptions = true onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE)
}, },
enabled = true, enabled = true,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(moreFocusRequester),
)
}
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,
) )
} }
} }
private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
@Composable @Composable
fun RightPlaybackButtons( fun RightPlaybackButtons(
subtitleStreams: List<SubtitleStream>, captionFocusRequester: FocusRequester,
settingsFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit, onControllerInteraction: () -> Unit,
onControllerInteractionForDialog: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
subtitleIndex: Int?,
audioStreams: List<AudioStream>,
audioIndex: Int?,
playbackSpeed: Float,
scale: ContentScale,
modifier: Modifier = Modifier, 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( Row(
modifier = modifier.focusGroup(), modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing), horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
@ -385,8 +327,8 @@ fun RightPlaybackButtons(
enabled = true, enabled = true,
iconRes = R.drawable.captions_svgrepo_com, iconRes = R.drawable.captions_svgrepo_com,
onClick = { onClick = {
onControllerInteractionForDialog.invoke() onControllerInteraction.invoke()
showCaptionDialog = true onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS)
}, },
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(captionFocusRequester), modifier = Modifier.focusRequester(captionFocusRequester),
@ -395,131 +337,14 @@ fun RightPlaybackButtons(
PlaybackButton( PlaybackButton(
iconRes = R.drawable.vector_settings, iconRes = R.drawable.vector_settings,
onClick = { onClick = {
onControllerInteractionForDialog.invoke() onControllerInteraction.invoke()
showOptionsDialog = true onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
}, },
enabled = true, enabled = true,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(settingsFocusRequester), 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) @OptIn(UnstableApi::class)
@ -629,7 +454,7 @@ fun PlaybackButton(
} }
@Composable @Composable
private fun BottomDialog( fun BottomDialog(
choices: List<String>, choices: List<String>,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onSelectChoice: (Int, String) -> 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 skipBackOnResume: Duration?,
private val oneClickPause: Boolean, private val oneClickPause: Boolean,
private val onInteraction: () -> Unit, private val onInteraction: () -> Unit,
private val onStop: () -> Unit,
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
) { ) {
fun onKeyEvent(it: KeyEvent): Boolean { fun onKeyEvent(it: KeyEvent): Boolean {
if (it.type == KeyEventType.KeyUp) onInteraction.invoke() if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
@ -34,12 +36,12 @@ class PlaybackKeyHandler(
result = false result = false
} else if (it.type != KeyEventType.KeyUp) { } else if (it.type != KeyEventType.KeyUp) {
result = false result = false
} else if (isDirectionalDpad(it) || isEnterKey(it)) { } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) { if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && it.key == Key.DirectionLeft) { if (skipWithLeftRight && isSkipBack(it)) {
updateSkipIndicator(-seekBack.inWholeMilliseconds) updateSkipIndicator(-seekBack.inWholeMilliseconds)
player.seekBack(seekBack) player.seekBack(seekBack)
} else if (skipWithLeftRight && it.key == Key.DirectionRight) { } else if (skipWithLeftRight && isSkipForward(it)) {
player.seekForward(seekForward) player.seekForward(seekForward)
updateSkipIndicator(seekForward.inWholeMilliseconds) updateSkipIndicator(seekForward.inWholeMilliseconds)
} else if (oneClickPause && isEnterKey(it)) { } else if (oneClickPause && isEnterKey(it)) {
@ -94,11 +96,16 @@ class PlaybackKeyHandler(
Key.MediaNext -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext() 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.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 -> result = false
} }
} else if (it.key == Key.Enter && !controllerViewState.controlsVisible) { } else if (isEnterKey(it) && !controllerViewState.controlsVisible) {
controllerViewState.showControls() controllerViewState.showControls()
} else if (it.key == Key.Back && controllerViewState.controlsVisible) { } else if (isBackKey(it) && controllerViewState.controlsVisible) {
// TODO change this to a BackHandler? // TODO change this to a BackHandler?
controllerViewState.hideControls() controllerViewState.hideControls()
} else { } 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.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent 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.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource 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.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter 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.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
@ -79,7 +77,6 @@ private val subtitleTextSize = 18.sp
@Composable @Composable
fun PlaybackOverlay( fun PlaybackOverlay(
item: BaseItem?, item: BaseItem?,
subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>, chapters: List<Chapter>,
playerControls: Player, playerControls: Player,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
@ -92,14 +89,10 @@ fun PlaybackOverlay(
skipBackOnResume: Duration?, skipBackOnResume: Duration?,
seekForward: Duration, seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekBarChange: (Long) -> Unit, onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean, showDebugInfo: Boolean,
scale: ContentScale,
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?, currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>,
currentSegment: MediaSegmentDto?, currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trickplayInfo: TrickplayInfo? = null, trickplayInfo: TrickplayInfo? = null,
@ -151,7 +144,6 @@ fun PlaybackOverlay(
Controller( Controller(
title = item?.title, title = item?.title,
subtitle = item?.subtitleLong, subtitle = item?.subtitleLong,
subtitleStreams = subtitleStreams,
playerControls = playerControls, playerControls = playerControls,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showPlay = showPlay, showPlay = showPlay,
@ -163,17 +155,11 @@ fun PlaybackOverlay(
skipBackOnResume = skipBackOnResume, skipBackOnResume = skipBackOnResume,
seekForward = seekForward, seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick, onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = onClickPlaybackDialogType,
onSeekProgress = { onSeekProgress = {
onSeekBarChange(it) onSeekBarChange(it)
seekProgressMs = it seekProgressMs = it
}, },
showDebugInfo = showDebugInfo,
scale = scale,
playbackSpeed = playbackSpeed,
moreButtonOptions = moreButtonOptions,
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams,
seekBarInteractionSource = seekBarInteractionSource, seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState, nextState = nextState,
onNextStateFocus = { onNextStateFocus = {
@ -442,7 +428,6 @@ enum class OverlayViewState {
@Composable @Composable
fun Controller( fun Controller(
title: String?, title: String?,
subtitleStreams: List<SubtitleStream>,
playerControls: Player, playerControls: Player,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
showClock: Boolean, showClock: Boolean,
@ -454,14 +439,8 @@ fun Controller(
skipBackOnResume: Duration?, skipBackOnResume: Duration?,
seekForward: Duration, seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onSeekProgress: (Long) -> Unit, onSeekProgress: (Long) -> Unit,
showDebugInfo: Boolean,
scale: ContentScale,
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>,
nextState: OverlayViewState?, nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?, currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier, 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( PlaybackControls(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
subtitleStreams = subtitleStreams,
playerControls = playerControls, playerControls = playerControls,
onPlaybackActionClick = onPlaybackActionClick, onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showDebugInfo = showDebugInfo,
onSeekProgress = { onSeekProgress = {
onSeekProgress(it) onSeekProgress(it)
}, },
@ -535,17 +516,15 @@ fun Controller(
nextEnabled = nextEnabled, nextEnabled = nextEnabled,
seekEnabled = seekEnabled, seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource, seekBarInteractionSource = seekBarInteractionSource,
moreButtonOptions = moreButtonOptions,
subtitleIndex = currentItemPlayback.subtitleIndex,
audioIndex = currentItemPlayback.audioIndex,
audioStreams = audioStreams,
playbackSpeed = playbackSpeed,
scale = scale,
seekBarIntervals = 16, seekBarIntervals = 16,
seekBack = seekBack, seekBack = seekBack,
seekForward = seekForward, seekForward = seekForward,
skipBackOnResume = skipBackOnResume, skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment, currentSegment = currentSegment,
onClickPlaybackDialogType = onClickPlaybackDialogType,
moreFocusRequester = moreFocusRequester,
captionFocusRequester = captionFocusRequester,
settingsFocusRequester = settingsFocusRequester,
) )
when (nextState) { when (nextState) {
OverlayViewState.CHAPTERS -> OverlayViewState.CHAPTERS ->

View file

@ -142,6 +142,8 @@ fun PlaybackPage(
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
if (prefs.playerBackend == PlayerBackend.MPV) { if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) { scope.launch(Dispatchers.Main + ExceptionHandler()) {
@ -202,8 +204,58 @@ fun PlaybackPage(
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onInteraction = viewModel::reportInteraction, onInteraction = viewModel::reportInteraction,
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, 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 = val showSegment =
!segmentCancelled && currentSegment != null && !segmentCancelled && currentSegment != null &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
@ -320,59 +372,11 @@ fun PlaybackPage(
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onPlaybackActionClick = { onPlaybackActionClick = onPlaybackActionClick,
when (it) { onClickPlaybackDialogType = { playbackDialog = 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()
}
}
}
},
onSeekBarChange = seekBarState::onValueChange, onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo, showDebugInfo = showDebugInfo,
scale = contentScale,
playbackSpeed = playbackSpeed,
moreButtonOptions = MoreButtonOptions(mapOf()),
currentPlayback = currentPlayback, currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = mediaInfo?.audioStreams ?: listOf(),
subtitleStreams = mediaInfo?.subtitleStreams ?: listOf(),
chapters = mediaInfo?.chapters ?: listOf(), chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo, trickplayInfo = mediaInfo?.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl, 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,
)
}
} }
} }
} }