diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/MobileGestureModifier.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/MobileGestureModifier.kt new file mode 100644 index 000000000..a00a865d9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/MobileGestureModifier.kt @@ -0,0 +1,159 @@ +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) +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt index 836c902d4..e44d898e2 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt index 32d63fd13..4e49ee237 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt @@ -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 @@ -771,6 +772,8 @@ fun PlaybackPageContent( updateSkipIndicator = updateSkipIndicator, ) } + val mobileTouchGesturesEnabled = isNotTvDevice && uiConfig.preferences.playbackPreferences.mobileTouchGestures + Box( modifier .background(Color.Black) @@ -778,22 +781,33 @@ fun PlaybackPageContent( .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 diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt index a5add7d7b..cf53c2e8b 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt @@ -149,6 +149,7 @@ val advancedPreferences = StashPreference.SavePlayHistory, StashPreference.DPadSkipping, StashPreference.DPadSkipIndicator, + StashPreference.MobileTouchGestures, StashPreference.ControllerTimeout, StashPreference.StartPlaybackMuted, StashPreference.PlaybackStreamChoice, diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt index 428c27385..b0b716ebf 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt @@ -922,6 +922,19 @@ sealed interface StashPreference { 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, diff --git a/app/src/main/proto/preferences.proto b/app/src/main/proto/preferences.proto index d22f6319b..3b2a7a392 100644 --- a/app/src/main/proto/preferences.proto +++ b/app/src/main/proto/preferences.proto @@ -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 { diff --git a/app/src/main/res/values/preferences.xml b/app/src/main/res/values/preferences.xml index b5490db45..8ba0aec5d 100644 --- a/app/src/main/res/values/preferences.xml +++ b/app/src/main/res/values/preferences.xml @@ -36,6 +36,7 @@ skipWithDpad playbackFinishedBehavior playback.showDpadSkip + playback.mobileTouchGestures playback.debug.logging playback.http.client playback.backend diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a852fdf6..e4a62c072 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -183,6 +183,9 @@ Skip back/forward with D-Pad left/right D-Pad left/right will not skip Show D-Pad skipping indicator + Mobile touch gestures + Pinch-to-zoom, swipe, and tap gestures enabled + Touch gestures disabled Stream choice Which stream type to use when direct is unavailable Playback debug info