Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.github.damontecres.stashapp.ui.components.playback

import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import kotlin.math.abs

private const val SWIPE_NEXT_PREV_THRESHOLD_PX = 200f
private const val GESTURE_MAX_ZOOM = 5f
private const val GESTURE_DOUBLE_TAP_ZOOM = 2f
private const val GESTURE_SLOW_SPEED = 0.5f
private const val GESTURE_FAST_SPEED = 2.0f

/**
* Returns a [Modifier] that adds mobile touch gestures to a video surface:
* - Pinch-to-zoom and drag-to-pan while zoomed
* - Double-tap left/right to skip back/forward; center to toggle zoom
* - Long-press left/right for 0.5x/2x speed (resets on release)
* - Horizontal swipe to navigate to the previous/next media item
*
* Requires the surface to use [androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW]
* so that [graphicsLayer] transforms are applied correctly.
*/
@Composable
fun rememberMobileGestureModifier(
player: Player,
controllerViewState: ControllerViewState,
updateSkipIndicator: (Long) -> Unit,
): Modifier {
var zoomFactor by remember { mutableFloatStateOf(1f) }
var panX by remember { mutableFloatStateOf(0f) }
var panY by remember { mutableFloatStateOf(0f) }
var surfaceWidth by remember { mutableIntStateOf(0) }
var surfaceHeight by remember { mutableIntStateOf(0) }
var gestureSpeedActive by remember { mutableStateOf(false) }
DisposableEffect(player) {
onDispose {
if (gestureSpeedActive) {
player.setPlaybackParameters(PlaybackParameters(1f))
gestureSpeedActive = false
}
}
}
val transformState = rememberTransformableState { zoomChange, offsetChange, _ ->
zoomFactor = (zoomFactor * zoomChange).coerceIn(1f, GESTURE_MAX_ZOOM)
if (zoomFactor > 1f) {
val maxX = surfaceWidth * (zoomFactor - 1f) / 2f
val maxY = surfaceHeight * (zoomFactor - 1f) / 2f
panX = (panX + offsetChange.x * zoomFactor).coerceIn(-maxX, maxX)
panY = (panY + offsetChange.y * zoomFactor).coerceIn(-maxY, maxY)
} else {
panX = 0f
panY = 0f
}
}

return Modifier
.onSizeChanged { surfaceWidth = it.width; surfaceHeight = it.height }
.graphicsLayer {
scaleX = zoomFactor
scaleY = zoomFactor
translationX = panX
translationY = panY
}
.pointerInput(Unit) {
detectTapGestures(
onPress = {
tryAwaitRelease()
if (gestureSpeedActive) {
player.setPlaybackParameters(PlaybackParameters(1f))
gestureSpeedActive = false
}
},
onTap = {
controllerViewState.toggleControls()
},
onDoubleTap = { offset ->
when {
offset.x < size.width / 3f -> {
player.seekBack()
updateSkipIndicator(-player.seekBackIncrement)
}
offset.x > size.width * 2f / 3f -> {
player.seekForward()
updateSkipIndicator(player.seekForwardIncrement)
}
else -> {
if (zoomFactor > 1f) {
zoomFactor = 1f
panX = 0f
panY = 0f
} else {
zoomFactor = GESTURE_DOUBLE_TAP_ZOOM
}
}
}
},
onLongPress = { offset ->
val speed = when {
offset.x < size.width / 3f -> GESTURE_SLOW_SPEED
offset.x > size.width * 2f / 3f -> GESTURE_FAST_SPEED
else -> null
}
if (speed != null) {
player.setPlaybackParameters(PlaybackParameters(speed))
gestureSpeedActive = true
}
},
)
}
// Swipe left/right to go to next/previous item. Uses PointerEventPass.Initial to
// observe move events before transformable() consumes them in the Main pass, and
// requireUnconsumed=false so detectTapGestures' down.consume() doesn't block us.
.pointerInput(zoomFactor > 1f) {
if (zoomFactor <= 1f) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
var dragX = 0f
var dragY = 0f
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
dragX += change.position.x - change.previousPosition.x
dragY += change.position.y - change.previousPosition.y
}
if (abs(dragX) > SWIPE_NEXT_PREV_THRESHOLD_PX && abs(dragX) > abs(dragY)) {
if (dragX < 0 && player.hasNextMediaItem()) {
player.seekToNext()
} else if (dragX > 0 && player.hasPreviousMediaItem()) {
player.seekToPrevious()
}
}
}
}
}
.transformable(transformState, lockRotationOnZoomPan = true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ class ControllerViewState internal constructor(
_controlsVisible = false
}

fun toggleControls() {
if (_controlsVisible) hideControls() else showControls()
}

fun pulseControls(milliseconds: Int = hideMilliseconds) {
// Log.i("PlaybackPageContent", "pulseControls=$milliseconds")
channel.trySend(milliseconds)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import androidx.media3.extractor.text.webvtt.WebvttParser
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberNextButtonState
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
Expand Down Expand Up @@ -771,29 +772,41 @@ fun PlaybackPageContent(
updateSkipIndicator = updateSkipIndicator,
)
}
val mobileTouchGesturesEnabled = isNotTvDevice && uiConfig.preferences.playbackPreferences.mobileTouchGestures

Box(
modifier
.background(Color.Black)
.onKeyEvent(playbackKeyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
scaledModifier.clickable(
enabled = !isTvDevice,
indication = null,
interactionSource = null,
) {
if (controllerViewState.controlsVisible) {
controllerViewState.hideControls()
} else {
controllerViewState.showControls()
}
},
)
if (mobileTouchGesturesEnabled) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_TEXTURE_VIEW,
modifier = scaledModifier.then(
rememberMobileGestureModifier(
player = player,
controllerViewState = controllerViewState,
updateSkipIndicator = updateSkipIndicator,
),
),
)
} else {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
scaledModifier.clickable(
enabled = !isTvDevice,
indication = null,
interactionSource = null,
) {
controllerViewState.toggleControls()
},
)
}
if (presentationState.coverSurface) {
Box(
Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ val advancedPreferences =
StashPreference.SavePlayHistory,
StashPreference.DPadSkipping,
StashPreference.DPadSkipIndicator,
StashPreference.MobileTouchGestures,
StashPreference.ControllerTimeout,
StashPreference.StartPlaybackMuted,
StashPreference.PlaybackStreamChoice,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,19 @@ sealed interface StashPreference<T> {
summaryOff = R.string.stashapp_actions_hide,
)

val MobileTouchGestures =
StashSwitchPreference(
title = R.string.mobile_touch_gestures,
prefKey = R.string.pref_key_mobile_touch_gestures,
defaultValue = false,
getter = { it.playbackPreferences.mobileTouchGestures },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { mobileTouchGestures = value }
},
summaryOn = R.string.mobile_touch_gestures_summary_on,
summaryOff = R.string.mobile_touch_gestures_summary_off,
)

val ControllerTimeout =
StashSliderPreference(
title = R.string.hide_controller_timeout,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/proto/preferences.proto
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ message PlaybackPreferences {
repeated string direct_play_audio = 17;
repeated string direct_play_format = 18;
PlaybackBackend playback_backend = 19;
bool mobile_touch_gestures = 20;
}

message CachePreferences {
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<string name="pref_key_skip_with_dpad" translatable="false">skipWithDpad</string>
<string name="pref_key_playback_finished_behavior" translatable="false">playbackFinishedBehavior</string>
<string name="pref_key_show_dpad_skip" translatable="false">playback.showDpadSkip</string>
<string name="pref_key_mobile_touch_gestures" translatable="false">playback.mobileTouchGestures</string>
<string name="pref_key_playback_debug_logging" translatable="false">playback.debug.logging</string>
<string name="pref_key_playback_http_client" translatable="false">playback.http.client</string>
<string name="pref_key_playback_backend" translatable="false">playback.backend</string>
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@
<string name="dpad_skipping_summary_on">Skip back/forward with D-Pad left/right</string>
<string name="dpad_skipping_summary_off">D-Pad left/right will not skip</string>
<string name="dpad_skip_indicator">Show D-Pad skipping indicator</string>
<string name="mobile_touch_gestures">Mobile touch gestures</string>
<string name="mobile_touch_gestures_summary_on">Pinch-to-zoom, swipe, and tap gestures enabled</string>
<string name="mobile_touch_gestures_summary_off">Touch gestures disabled</string>
<string name="stream_choice">Stream choice</string>
<string name="stream_choice_summary">Which stream type to use when direct is unavailable</string>
<string name="playback_debug_info">Playback debug info</string>
Expand Down