From 4ddbf3f0a6b9e8d553a208ce4ba09be69994a092 Mon Sep 17 00:00:00 2001 From: my-alt-gh-acct <270794820+my-alt-gh-acct@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:21:54 +0000 Subject: [PATCH 01/10] Add mobile touch gestures for video playback - Pinch-to-zoom and drag-to-pan on the video surface (Compose path) - Double-tap left/right edges to skip back/forward - Double-tap center to toggle 2x zoom - Long-press left/right edges for 0.5x/2x speed (releases on finger up) - Horizontal swipe to navigate to previous/next queue item - New "Mobile touch gestures" toggle in playback settings - Defaults to enabled on non-TV devices for fresh installs - Legacy XML playback path also wired up via MobileGestureHandler Co-Authored-By: Claude Sonnet 4.6 --- .../stashapp/playback/MobileGestureHandler.kt | 223 ++++++++++++++++++ .../stashapp/playback/PlaybackFragment.kt | 30 +++ .../stashapp/playback/StashPlayerView.kt | 11 + .../playback/PlaybackPageContent.kt | 149 ++++++++++-- .../ui/components/prefs/PreferencesContent.kt | 1 + .../ui/components/prefs/StashPreference.kt | 13 + .../util/StashPreferencesSerializer.kt | 2 + app/src/main/proto/preferences.proto | 1 + app/src/main/res/layout/video_playback.xml | 15 +- app/src/main/res/values/preferences.xml | 1 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/advanced_preferences.xml | 6 + 12 files changed, 439 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt diff --git a/app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt b/app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt new file mode 100644 index 000000000..03ddeb4fc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt @@ -0,0 +1,223 @@ +package com.github.damontecres.stashapp.playback + +import android.content.Context +import android.graphics.Matrix +import android.util.Log +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.TextureView +import android.view.View +import android.widget.TextView +import androidx.core.view.GestureDetectorCompat +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.util.UnstableApi +import com.github.damontecres.stashapp.views.SkipIndicator +import kotlin.math.abs + +/** + * Handles mobile touch gestures for video playback: + * - Pinch to zoom + double-tap center to toggle zoom + * - Drag to pan while zoomed + * - Double-tap left/right edges to skip back/forward + * - Long-press left/right edges to slow down (0.5x) / speed up (2x) + * - Horizontal fling to go to next/previous item in queue + * + * Must be assigned to [StashPlayerView.gestureHandler] after the view is created. + * Reads [StashPlayerView.player] lazily so it is safe to construct before the player is attached. + */ +@UnstableApi +class MobileGestureHandler( + private val context: Context, + private val playerView: StashPlayerView, + private val skipIndicator: SkipIndicator?, + private val speedOverlay: TextView?, +) : GestureDetector.SimpleOnGestureListener(), + ScaleGestureDetector.OnScaleGestureListener { + + private val gestureDetector = GestureDetectorCompat(context, this) + private val scaleDetector = ScaleGestureDetector(context, this) + + // Zoom / pan state + private var currentScale = 1f + private var translateX = 0f + private var translateY = 0f + + private var isSpeedModified = false + + companion object { + private const val TAG = "MobileGestureHandler" + private const val MAX_SCALE = 5f + private const val DOUBLE_TAP_ZOOM_SCALE = 2f + // Minimum fling velocity in dp/s to trigger track change + private const val FLING_VELOCITY_DP = 1000f + } + + private val flingThresholdPx: Float by lazy { + FLING_VELOCITY_DP * context.resources.displayMetrics.density + } + + // ── Tap zone helpers ───────────────────────────────────────────────────── + + private enum class TapZone { LEFT, MIDDLE, RIGHT } + + private fun getTapZone(x: Float): TapZone { + val third = playerView.width / 3f + return when { + x < third -> TapZone.LEFT + x < third * 2f -> TapZone.MIDDLE + else -> TapZone.RIGHT + } + } + + // ── Transform helpers ──────────────────────────────────────────────────── + + private fun clampTranslation() { + val maxX = playerView.width * (currentScale - 1f) / 2f + val maxY = playerView.height * (currentScale - 1f) / 2f + translateX = translateX.coerceIn(-maxX, maxX) + translateY = translateY.coerceIn(-maxY, maxY) + } + + private fun applyTransform() { + val textureView = playerView.videoSurfaceView as? TextureView + if (textureView == null) { + Log.w(TAG, "applyTransform: videoSurfaceView is not a TextureView " + + "(actual type: ${playerView.videoSurfaceView?.javaClass?.simpleName}). " + + "Check that surface_type=\"texture_view\" is set in video_playback.xml.") + return + } + val matrix = Matrix() + matrix.postScale( + currentScale, currentScale, + playerView.width / 2f, + playerView.height / 2f, + ) + matrix.postTranslate(translateX, translateY) + textureView.setTransform(matrix) + } + + private fun resetZoom() { + currentScale = 1f + translateX = 0f + translateY = 0f + applyTransform() + } + + // ── ScaleGestureDetector: pinch to zoom ────────────────────────────────── + + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true + + override fun onScale(detector: ScaleGestureDetector): Boolean { + currentScale = (currentScale * detector.scaleFactor).coerceIn(1f, MAX_SCALE) + if (currentScale == 1f) { + translateX = 0f + translateY = 0f + } + clampTranslation() + applyTransform() + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) {} + + // ── GestureDetector: double-tap ────────────────────────────────────────── + + override fun onDoubleTap(e: MotionEvent): Boolean { + when (getTapZone(e.x)) { + TapZone.MIDDLE -> { + if (currentScale > 1f) resetZoom() else { + currentScale = DOUBLE_TAP_ZOOM_SCALE + applyTransform() + } + } + TapZone.LEFT -> { + val player = playerView.player ?: return true + player.seekBack() + skipIndicator?.update(-player.seekBackIncrement) + } + TapZone.RIGHT -> { + val player = playerView.player ?: return true + player.seekForward() + skipIndicator?.update(player.seekForwardIncrement) + } + } + return true + } + + // ── GestureDetector: drag to pan while zoomed ──────────────────────────── + + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + if (currentScale <= 1f) return false + translateX -= distanceX + translateY -= distanceY + clampTranslation() + applyTransform() + return true + } + + // ── GestureDetector: long-press to change speed ────────────────────────── + + override fun onLongPress(e: MotionEvent) { + val zone = getTapZone(e.x) + val speed = when (zone) { + TapZone.LEFT -> 0.5f + TapZone.RIGHT -> 2.0f + TapZone.MIDDLE -> return + } + val player = playerView.player ?: return + player.setPlaybackParameters(PlaybackParameters(speed)) + speedOverlay?.text = "${speed}x" + speedOverlay?.visibility = View.VISIBLE + isSpeedModified = true + } + + // ── GestureDetector: fling to skip to next/previous ───────────────────── + + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float, + ): Boolean { + if (abs(velocityX) < flingThresholdPx) return false + val player = playerView.player ?: return false + return when { + velocityX < 0 && player.hasNextMediaItem() -> { player.seekToNext(); true } + velocityX > 0 && player.hasPreviousMediaItem() -> { player.seekToPrevious(); true } + else -> false + } + } + + // ── Main touch entry point (called from StashPlayerView.dispatchTouchEvent) ── + + fun onTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + if (isSpeedModified) { + playerView.player?.setPlaybackParameters(PlaybackParameters(1.0f)) + speedOverlay?.visibility = View.GONE + isSpeedModified = false + } + } + scaleDetector.onTouchEvent(event) + return gestureDetector.onTouchEvent(event) + } + + /** + * Call from [PlaybackFragment.onDestroyView] to ensure playback speed is restored + * if the app is backgrounded mid-long-press. + */ + fun release() { + if (isSpeedModified) { + playerView.player?.setPlaybackParameters(PlaybackParameters(1.0f)) + speedOverlay?.visibility = View.GONE + isSpeedModified = false + } + resetZoom() + } +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt index 8f59de7ad..f5bbf4bfb 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt @@ -48,6 +48,7 @@ import com.github.damontecres.stashapp.util.OCounterLongClickCallBack import com.github.damontecres.stashapp.util.SkipParams import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler import com.github.damontecres.stashapp.util.StashPreviewLoader +import com.github.damontecres.stashapp.ui.compat.detectTvDevice import com.github.damontecres.stashapp.util.animateToInvisible import com.github.damontecres.stashapp.util.animateToVisible import com.github.damontecres.stashapp.util.getDataType @@ -636,6 +637,32 @@ abstract class PlaybackFragment( player = preparePlayer() player!!.postSetupPlayer() super.onStart() + updateGestureHandler() + } + + private fun updateGestureHandler() { + val mobilePrefKey = getString(R.string.pref_key_mobile_touch_gestures) + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val prefExists = prefs.contains(mobilePrefKey) + val gesturesEnabled = if (prefExists) { + prefs.getBoolean(mobilePrefKey, false) + } else { + !detectTvDevice + } + if (gesturesEnabled) { + if (videoView.gestureHandler == null) { + val speedOverlay = view?.findViewById(R.id.speed_overlay_text) + videoView.gestureHandler = MobileGestureHandler( + context = requireContext(), + playerView = videoView, + skipIndicator = skipIndicator, + speedOverlay = speedOverlay, + ) + } + } else { + videoView.gestureHandler?.release() + videoView.gestureHandler = null + } } @OptIn(UnstableApi::class) @@ -678,12 +705,15 @@ abstract class PlaybackFragment( @OptIn(UnstableApi::class) override fun onStop() { Log.v(TAG, "onStop") + videoView.gestureHandler?.release() releasePlayer() keepScreenOn(false) super.onStop() } override fun onDestroyView() { + videoView.gestureHandler?.release() + videoView.gestureHandler = null super.onDestroyView() controllerVisibilityListener.removeAllListeners() videoView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?) diff --git a/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt b/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt index 20db29b0f..13042a016 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt @@ -3,6 +3,7 @@ package com.github.damontecres.stashapp.playback import android.content.Context import android.util.AttributeSet import android.view.KeyEvent +import android.view.MotionEvent import androidx.annotation.OptIn import androidx.fragment.app.Fragment import androidx.fragment.app.findFragment @@ -29,6 +30,8 @@ class StashPlayerView( var skipIndicator: SkipIndicator? = null + var gestureHandler: MobileGestureHandler? = null + @OptIn(UnstableApi::class) override fun dispatchKeyEvent(event: KeyEvent): Boolean { val keyCode = event.keyCode @@ -89,6 +92,14 @@ class StashPlayerView( return super.dispatchKeyEvent(event) } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + val handler = gestureHandler + if (handler != null && useController) { + if (handler.onTouchEvent(event)) return true + } + return super.dispatchTouchEvent(event) + } + companion object { const val TAG = "StashPlayerView" } 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..2cbadfe9d 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 @@ -9,6 +9,10 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -36,13 +40,16 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -58,6 +65,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride @@ -72,6 +80,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 @@ -139,6 +148,11 @@ import kotlin.time.Duration.Companion.microseconds import kotlin.time.Duration.Companion.milliseconds const val TAG = "PlaybackPageContent" +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 class PlaybackViewModel : ViewModel() { private lateinit var server: StashServer @@ -510,6 +524,27 @@ fun PlaybackPageContent( val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) + // Mobile touch gesture state + val mobileTouchGesturesEnabled = isNotTvDevice && uiConfig.preferences.playbackPreferences.mobileTouchGestures + 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) } + 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).coerceIn(-maxX, maxX) + panY = (panY + offsetChange.y).coerceIn(-maxY, maxY) + } else { + panX = 0f + panY = 0f + } + } + var contentCurrentPosition by remember { mutableLongStateOf(0L) } var createMarkerPosition by remember { mutableLongStateOf(-1L) } @@ -778,22 +813,106 @@ 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 + .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 = { + if (controllerViewState.controlsVisible) { + controllerViewState.hideControls() + } else { + controllerViewState.showControls() + } + }, + 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 -> return@detectTapGestures + } + player.setPlaybackParameters(PlaybackParameters(speed)) + gestureSpeedActive = true + }, + ) + } + .pointerInput(zoomFactor) { + if (zoomFactor <= 1f) { + var dragX = 0f + detectDragGestures( + onDragStart = { dragX = 0f }, + onDragCancel = { dragX = 0f }, + onDragEnd = { + if (dragX < -SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasNextMediaItem()) { + player.seekToNext() + } else if (dragX > SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasPreviousMediaItem()) { + player.seekToPrevious() + } + dragX = 0f + }, + ) { change, dragAmount -> + dragX += dragAmount.x + change.consume() + } + } + } + .transformable(transformState, lockRotationOnZoomPan = true), + ) + } else { + 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 (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/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt index fc0429481..ac90e5666 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt @@ -16,6 +16,7 @@ import com.github.damontecres.stashapp.proto.StashPreferences import com.github.damontecres.stashapp.proto.StreamChoice import com.github.damontecres.stashapp.proto.TabPreferences import com.github.damontecres.stashapp.proto.UpdatePreferences +import com.github.damontecres.stashapp.ui.compat.detectTvDevice import com.github.damontecres.stashapp.ui.components.prefs.StashPreference import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream @@ -83,6 +84,7 @@ object StashPreferencesSerializer : Serializer { addAllDirectPlayVideo(StashPreference.DirectPlayVideo.defaultValue) addAllDirectPlayAudio(StashPreference.DirectPlayAudio.defaultValue) addAllDirectPlayFormat(StashPreference.DirectPlayFormat.defaultValue) + mobileTouchGestures = !detectTvDevice }.build() updatePreferences = UpdatePreferences 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/layout/video_playback.xml b/app/src/main/res/layout/video_playback.xml index e4d835e5b..25cd9f3a2 100644 --- a/app/src/main/res/layout/video_playback.xml +++ b/app/src/main/res/layout/video_playback.xml @@ -19,13 +19,26 @@ app:played_color="@color/selected_background" app:use_controller="true" app:hide_during_ads="false" - app:show_subtitle_button="true" /> + app:show_subtitle_button="true" + app:surface_type="texture_view" /> + + 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 diff --git a/app/src/main/res/xml/advanced_preferences.xml b/app/src/main/res/xml/advanced_preferences.xml index 293e6deb8..383ecf817 100644 --- a/app/src/main/res/xml/advanced_preferences.xml +++ b/app/src/main/res/xml/advanced_preferences.xml @@ -62,6 +62,12 @@ app:key="@string/pref_key_show_dpad_skip" app:title="Show D-Pad skipping indicator" app:defaultValue="true" /> + Date: Tue, 24 Mar 2026 23:30:46 +0000 Subject: [PATCH 02/10] Refactor: extract mobile gesture modifier into named val Moves the gesture modifier chain into mobileTouchGestureModifier before the Box, keeping the PlayerSurface if/else branches clean and readable. Co-Authored-By: Claude Sonnet 4.6 --- .../playback/PlaybackPageContent.kt | 155 +++++++++--------- 1 file changed, 78 insertions(+), 77 deletions(-) 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 2cbadfe9d..de332fb87 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 @@ -544,6 +544,83 @@ fun PlaybackPageContent( panY = 0f } } + val mobileTouchGestureModifier = scaledModifier + .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 = { + if (controllerViewState.controlsVisible) { + controllerViewState.hideControls() + } else { + controllerViewState.showControls() + } + }, + 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 -> return@detectTapGestures + } + player.setPlaybackParameters(PlaybackParameters(speed)) + gestureSpeedActive = true + }, + ) + } + .pointerInput(zoomFactor) { + if (zoomFactor <= 1f) { + var dragX = 0f + detectDragGestures( + onDragStart = { dragX = 0f }, + onDragCancel = { dragX = 0f }, + onDragEnd = { + if (dragX < -SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasNextMediaItem()) { + player.seekToNext() + } else if (dragX > SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasPreviousMediaItem()) { + player.seekToPrevious() + } + dragX = 0f + }, + ) { change, dragAmount -> + dragX += dragAmount.x + change.consume() + } + } + } + .transformable(transformState, lockRotationOnZoomPan = true) var contentCurrentPosition by remember { mutableLongStateOf(0L) } @@ -817,83 +894,7 @@ fun PlaybackPageContent( PlayerSurface( player = player, surfaceType = SURFACE_TYPE_TEXTURE_VIEW, - modifier = scaledModifier - .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 = { - if (controllerViewState.controlsVisible) { - controllerViewState.hideControls() - } else { - controllerViewState.showControls() - } - }, - 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 -> return@detectTapGestures - } - player.setPlaybackParameters(PlaybackParameters(speed)) - gestureSpeedActive = true - }, - ) - } - .pointerInput(zoomFactor) { - if (zoomFactor <= 1f) { - var dragX = 0f - detectDragGestures( - onDragStart = { dragX = 0f }, - onDragCancel = { dragX = 0f }, - onDragEnd = { - if (dragX < -SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasNextMediaItem()) { - player.seekToNext() - } else if (dragX > SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasPreviousMediaItem()) { - player.seekToPrevious() - } - dragX = 0f - }, - ) { change, dragAmount -> - dragX += dragAmount.x - change.consume() - } - } - } - .transformable(transformState, lockRotationOnZoomPan = true), + modifier = mobileTouchGestureModifier, ) } else { PlayerSurface( From 54347e520c1100ed42bc2f3c2c3b435351441490 Mon Sep 17 00:00:00 2001 From: my-alt-gh-acct <270794820+my-alt-gh-acct@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:31:07 +0000 Subject: [PATCH 03/10] Default mobile touch gestures to off Let users opt in rather than enabling by default on fresh installs. Co-Authored-By: Claude Sonnet 4.6 --- .../damontecres/stashapp/util/StashPreferencesSerializer.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt index ac90e5666..fc0429481 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt @@ -16,7 +16,6 @@ import com.github.damontecres.stashapp.proto.StashPreferences import com.github.damontecres.stashapp.proto.StreamChoice import com.github.damontecres.stashapp.proto.TabPreferences import com.github.damontecres.stashapp.proto.UpdatePreferences -import com.github.damontecres.stashapp.ui.compat.detectTvDevice import com.github.damontecres.stashapp.ui.components.prefs.StashPreference import com.google.protobuf.InvalidProtocolBufferException import java.io.InputStream @@ -84,7 +83,6 @@ object StashPreferencesSerializer : Serializer { addAllDirectPlayVideo(StashPreference.DirectPlayVideo.defaultValue) addAllDirectPlayAudio(StashPreference.DirectPlayAudio.defaultValue) addAllDirectPlayFormat(StashPreference.DirectPlayFormat.defaultValue) - mobileTouchGestures = !detectTvDevice }.build() updatePreferences = UpdatePreferences From 973413148dfb779629c6323c8c339decf87b42ce Mon Sep 17 00:00:00 2001 From: my-alt-gh-acct <270794820+my-alt-gh-acct@users.noreply.github.com> Date: Wed, 25 Mar 2026 03:16:07 +0000 Subject: [PATCH 04/10] Extract mobile gesture modifier into rememberMobileGestureModifier Moves all gesture state and modifier chain into a dedicated MobileGestureModifier.kt composable, keeping PlaybackPageContent focused on playback UI layout. Co-Authored-By: Claude Sonnet 4.6 --- .../playback/MobileGestureModifier.kt | 140 ++++++++++++++++++ .../playback/PlaybackPageContent.kt | 119 +-------------- 2 files changed, 148 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/MobileGestureModifier.kt 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..105778349 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/MobileGestureModifier.kt @@ -0,0 +1,140 @@ +package com.github.damontecres.stashapp.ui.components.playback + +import androidx.compose.foundation.gestures.detectDragGestures +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.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.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player + +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( + scaledModifier: Modifier, + 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) } + 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).coerceIn(-maxX, maxX) + panY = (panY + offsetChange.y).coerceIn(-maxY, maxY) + } else { + panX = 0f + panY = 0f + } + } + + return scaledModifier + .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 = { + if (controllerViewState.controlsVisible) { + controllerViewState.hideControls() + } else { + controllerViewState.showControls() + } + }, + 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 -> return@detectTapGestures + } + player.setPlaybackParameters(PlaybackParameters(speed)) + gestureSpeedActive = true + }, + ) + } + .pointerInput(zoomFactor) { + if (zoomFactor <= 1f) { + var dragX = 0f + detectDragGestures( + onDragStart = { dragX = 0f }, + onDragCancel = { dragX = 0f }, + onDragEnd = { + if (dragX < -SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasNextMediaItem()) { + player.seekToNext() + } else if (dragX > SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasPreviousMediaItem()) { + player.seekToPrevious() + } + dragX = 0f + }, + ) { change, dragAmount -> + dragX += dragAmount.x + change.consume() + } + } + } + .transformable(transformState, lockRotationOnZoomPan = true) +} 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 de332fb87..c1929bc48 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 @@ -9,10 +9,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.rememberTransformableState -import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -40,16 +36,13 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -65,7 +58,6 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.C import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride @@ -148,11 +140,6 @@ import kotlin.time.Duration.Companion.microseconds import kotlin.time.Duration.Companion.milliseconds const val TAG = "PlaybackPageContent" -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 class PlaybackViewModel : ViewModel() { private lateinit var server: StashServer @@ -524,104 +511,6 @@ fun PlaybackPageContent( val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) - // Mobile touch gesture state - val mobileTouchGesturesEnabled = isNotTvDevice && uiConfig.preferences.playbackPreferences.mobileTouchGestures - 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) } - 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).coerceIn(-maxX, maxX) - panY = (panY + offsetChange.y).coerceIn(-maxY, maxY) - } else { - panX = 0f - panY = 0f - } - } - val mobileTouchGestureModifier = scaledModifier - .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 = { - if (controllerViewState.controlsVisible) { - controllerViewState.hideControls() - } else { - controllerViewState.showControls() - } - }, - 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 -> return@detectTapGestures - } - player.setPlaybackParameters(PlaybackParameters(speed)) - gestureSpeedActive = true - }, - ) - } - .pointerInput(zoomFactor) { - if (zoomFactor <= 1f) { - var dragX = 0f - detectDragGestures( - onDragStart = { dragX = 0f }, - onDragCancel = { dragX = 0f }, - onDragEnd = { - if (dragX < -SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasNextMediaItem()) { - player.seekToNext() - } else if (dragX > SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasPreviousMediaItem()) { - player.seekToPrevious() - } - dragX = 0f - }, - ) { change, dragAmount -> - dragX += dragAmount.x - change.consume() - } - } - } - .transformable(transformState, lockRotationOnZoomPan = true) - var contentCurrentPosition by remember { mutableLongStateOf(0L) } var createMarkerPosition by remember { mutableLongStateOf(-1L) } @@ -883,6 +772,14 @@ fun PlaybackPageContent( updateSkipIndicator = updateSkipIndicator, ) } + val mobileTouchGesturesEnabled = isNotTvDevice && uiConfig.preferences.playbackPreferences.mobileTouchGestures + val mobileTouchGestureModifier = rememberMobileGestureModifier( + scaledModifier = scaledModifier, + player = player, + controllerViewState = controllerViewState, + updateSkipIndicator = updateSkipIndicator, + ) + Box( modifier .background(Color.Black) From b901e4ebe5b01d02437affe12eceab5f78d68821 Mon Sep 17 00:00:00 2001 From: my-alt-gh-acct <270794820+my-alt-gh-acct@users.noreply.github.com> Date: Wed, 25 Mar 2026 03:31:20 +0000 Subject: [PATCH 05/10] Fix speed reset on navigation and use string refs in XML - Add DisposableEffect to MobileGestureModifier to reset playback speed if the composable leaves composition mid-long-press - Use @string/ references in advanced_preferences.xml instead of inline literals, matching the string resources already defined in strings.xml Co-Authored-By: Claude Sonnet 4.6 --- .../ui/components/playback/MobileGestureModifier.kt | 9 +++++++++ app/src/main/res/xml/advanced_preferences.xml | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) 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 index 105778349..72e60053c 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -47,6 +48,14 @@ fun rememberMobileGestureModifier( 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) { diff --git a/app/src/main/res/xml/advanced_preferences.xml b/app/src/main/res/xml/advanced_preferences.xml index 383ecf817..55997982c 100644 --- a/app/src/main/res/xml/advanced_preferences.xml +++ b/app/src/main/res/xml/advanced_preferences.xml @@ -64,9 +64,9 @@ app:defaultValue="true" /> Date: Wed, 25 Mar 2026 03:35:08 +0000 Subject: [PATCH 06/10] Avoid degrading TV experience with gesture code - Remove surface_type=texture_view from video_playback.xml so the legacy path (primarily TV) keeps the default SurfaceView renderer - Move rememberMobileGestureModifier call inside the mobileTouchGesturesEnabled branch so gesture state is never allocated on TV devices Co-Authored-By: Claude Sonnet 4.6 --- .../ui/components/playback/PlaybackPageContent.kt | 13 ++++++------- app/src/main/res/layout/video_playback.xml | 3 +-- 2 files changed, 7 insertions(+), 9 deletions(-) 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 c1929bc48..3fa6a3700 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 @@ -773,12 +773,6 @@ fun PlaybackPageContent( ) } val mobileTouchGesturesEnabled = isNotTvDevice && uiConfig.preferences.playbackPreferences.mobileTouchGestures - val mobileTouchGestureModifier = rememberMobileGestureModifier( - scaledModifier = scaledModifier, - player = player, - controllerViewState = controllerViewState, - updateSkipIndicator = updateSkipIndicator, - ) Box( modifier @@ -791,7 +785,12 @@ fun PlaybackPageContent( PlayerSurface( player = player, surfaceType = SURFACE_TYPE_TEXTURE_VIEW, - modifier = mobileTouchGestureModifier, + modifier = rememberMobileGestureModifier( + scaledModifier = scaledModifier, + player = player, + controllerViewState = controllerViewState, + updateSkipIndicator = updateSkipIndicator, + ), ) } else { PlayerSurface( diff --git a/app/src/main/res/layout/video_playback.xml b/app/src/main/res/layout/video_playback.xml index 25cd9f3a2..c470b763f 100644 --- a/app/src/main/res/layout/video_playback.xml +++ b/app/src/main/res/layout/video_playback.xml @@ -19,8 +19,7 @@ app:played_color="@color/selected_background" app:use_controller="true" app:hide_during_ads="false" - app:show_subtitle_button="true" - app:surface_type="texture_view" /> + app:show_subtitle_button="true" /> Date: Wed, 25 Mar 2026 03:47:43 +0000 Subject: [PATCH 07/10] Remove legacy path gesture implementation The Compose path (PlaybackPageContent) is the active mobile UI. Anyone wanting mobile touch gestures will be on the Compose path, making MobileGestureHandler dead code in practice. Removes MobileGestureHandler, reverts PlaybackFragment and StashPlayerView to their original state, and drops the legacy settings XML entry. Co-Authored-By: Claude Sonnet 4.6 --- .../stashapp/playback/MobileGestureHandler.kt | 223 ------------------ .../stashapp/playback/PlaybackFragment.kt | 30 --- .../stashapp/playback/StashPlayerView.kt | 11 - app/src/main/res/layout/video_playback.xml | 12 - app/src/main/res/xml/advanced_preferences.xml | 6 - 5 files changed, 282 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt diff --git a/app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt b/app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt deleted file mode 100644 index 03ddeb4fc..000000000 --- a/app/src/main/java/com/github/damontecres/stashapp/playback/MobileGestureHandler.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.github.damontecres.stashapp.playback - -import android.content.Context -import android.graphics.Matrix -import android.util.Log -import android.view.GestureDetector -import android.view.MotionEvent -import android.view.ScaleGestureDetector -import android.view.TextureView -import android.view.View -import android.widget.TextView -import androidx.core.view.GestureDetectorCompat -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.util.UnstableApi -import com.github.damontecres.stashapp.views.SkipIndicator -import kotlin.math.abs - -/** - * Handles mobile touch gestures for video playback: - * - Pinch to zoom + double-tap center to toggle zoom - * - Drag to pan while zoomed - * - Double-tap left/right edges to skip back/forward - * - Long-press left/right edges to slow down (0.5x) / speed up (2x) - * - Horizontal fling to go to next/previous item in queue - * - * Must be assigned to [StashPlayerView.gestureHandler] after the view is created. - * Reads [StashPlayerView.player] lazily so it is safe to construct before the player is attached. - */ -@UnstableApi -class MobileGestureHandler( - private val context: Context, - private val playerView: StashPlayerView, - private val skipIndicator: SkipIndicator?, - private val speedOverlay: TextView?, -) : GestureDetector.SimpleOnGestureListener(), - ScaleGestureDetector.OnScaleGestureListener { - - private val gestureDetector = GestureDetectorCompat(context, this) - private val scaleDetector = ScaleGestureDetector(context, this) - - // Zoom / pan state - private var currentScale = 1f - private var translateX = 0f - private var translateY = 0f - - private var isSpeedModified = false - - companion object { - private const val TAG = "MobileGestureHandler" - private const val MAX_SCALE = 5f - private const val DOUBLE_TAP_ZOOM_SCALE = 2f - // Minimum fling velocity in dp/s to trigger track change - private const val FLING_VELOCITY_DP = 1000f - } - - private val flingThresholdPx: Float by lazy { - FLING_VELOCITY_DP * context.resources.displayMetrics.density - } - - // ── Tap zone helpers ───────────────────────────────────────────────────── - - private enum class TapZone { LEFT, MIDDLE, RIGHT } - - private fun getTapZone(x: Float): TapZone { - val third = playerView.width / 3f - return when { - x < third -> TapZone.LEFT - x < third * 2f -> TapZone.MIDDLE - else -> TapZone.RIGHT - } - } - - // ── Transform helpers ──────────────────────────────────────────────────── - - private fun clampTranslation() { - val maxX = playerView.width * (currentScale - 1f) / 2f - val maxY = playerView.height * (currentScale - 1f) / 2f - translateX = translateX.coerceIn(-maxX, maxX) - translateY = translateY.coerceIn(-maxY, maxY) - } - - private fun applyTransform() { - val textureView = playerView.videoSurfaceView as? TextureView - if (textureView == null) { - Log.w(TAG, "applyTransform: videoSurfaceView is not a TextureView " + - "(actual type: ${playerView.videoSurfaceView?.javaClass?.simpleName}). " + - "Check that surface_type=\"texture_view\" is set in video_playback.xml.") - return - } - val matrix = Matrix() - matrix.postScale( - currentScale, currentScale, - playerView.width / 2f, - playerView.height / 2f, - ) - matrix.postTranslate(translateX, translateY) - textureView.setTransform(matrix) - } - - private fun resetZoom() { - currentScale = 1f - translateX = 0f - translateY = 0f - applyTransform() - } - - // ── ScaleGestureDetector: pinch to zoom ────────────────────────────────── - - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true - - override fun onScale(detector: ScaleGestureDetector): Boolean { - currentScale = (currentScale * detector.scaleFactor).coerceIn(1f, MAX_SCALE) - if (currentScale == 1f) { - translateX = 0f - translateY = 0f - } - clampTranslation() - applyTransform() - return true - } - - override fun onScaleEnd(detector: ScaleGestureDetector) {} - - // ── GestureDetector: double-tap ────────────────────────────────────────── - - override fun onDoubleTap(e: MotionEvent): Boolean { - when (getTapZone(e.x)) { - TapZone.MIDDLE -> { - if (currentScale > 1f) resetZoom() else { - currentScale = DOUBLE_TAP_ZOOM_SCALE - applyTransform() - } - } - TapZone.LEFT -> { - val player = playerView.player ?: return true - player.seekBack() - skipIndicator?.update(-player.seekBackIncrement) - } - TapZone.RIGHT -> { - val player = playerView.player ?: return true - player.seekForward() - skipIndicator?.update(player.seekForwardIncrement) - } - } - return true - } - - // ── GestureDetector: drag to pan while zoomed ──────────────────────────── - - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float, - ): Boolean { - if (currentScale <= 1f) return false - translateX -= distanceX - translateY -= distanceY - clampTranslation() - applyTransform() - return true - } - - // ── GestureDetector: long-press to change speed ────────────────────────── - - override fun onLongPress(e: MotionEvent) { - val zone = getTapZone(e.x) - val speed = when (zone) { - TapZone.LEFT -> 0.5f - TapZone.RIGHT -> 2.0f - TapZone.MIDDLE -> return - } - val player = playerView.player ?: return - player.setPlaybackParameters(PlaybackParameters(speed)) - speedOverlay?.text = "${speed}x" - speedOverlay?.visibility = View.VISIBLE - isSpeedModified = true - } - - // ── GestureDetector: fling to skip to next/previous ───────────────────── - - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float, - ): Boolean { - if (abs(velocityX) < flingThresholdPx) return false - val player = playerView.player ?: return false - return when { - velocityX < 0 && player.hasNextMediaItem() -> { player.seekToNext(); true } - velocityX > 0 && player.hasPreviousMediaItem() -> { player.seekToPrevious(); true } - else -> false - } - } - - // ── Main touch entry point (called from StashPlayerView.dispatchTouchEvent) ── - - fun onTouchEvent(event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - if (isSpeedModified) { - playerView.player?.setPlaybackParameters(PlaybackParameters(1.0f)) - speedOverlay?.visibility = View.GONE - isSpeedModified = false - } - } - scaleDetector.onTouchEvent(event) - return gestureDetector.onTouchEvent(event) - } - - /** - * Call from [PlaybackFragment.onDestroyView] to ensure playback speed is restored - * if the app is backgrounded mid-long-press. - */ - fun release() { - if (isSpeedModified) { - playerView.player?.setPlaybackParameters(PlaybackParameters(1.0f)) - speedOverlay?.visibility = View.GONE - isSpeedModified = false - } - resetZoom() - } -} diff --git a/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt index f5bbf4bfb..8f59de7ad 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/playback/PlaybackFragment.kt @@ -48,7 +48,6 @@ import com.github.damontecres.stashapp.util.OCounterLongClickCallBack import com.github.damontecres.stashapp.util.SkipParams import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler import com.github.damontecres.stashapp.util.StashPreviewLoader -import com.github.damontecres.stashapp.ui.compat.detectTvDevice import com.github.damontecres.stashapp.util.animateToInvisible import com.github.damontecres.stashapp.util.animateToVisible import com.github.damontecres.stashapp.util.getDataType @@ -637,32 +636,6 @@ abstract class PlaybackFragment( player = preparePlayer() player!!.postSetupPlayer() super.onStart() - updateGestureHandler() - } - - private fun updateGestureHandler() { - val mobilePrefKey = getString(R.string.pref_key_mobile_touch_gestures) - val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val prefExists = prefs.contains(mobilePrefKey) - val gesturesEnabled = if (prefExists) { - prefs.getBoolean(mobilePrefKey, false) - } else { - !detectTvDevice - } - if (gesturesEnabled) { - if (videoView.gestureHandler == null) { - val speedOverlay = view?.findViewById(R.id.speed_overlay_text) - videoView.gestureHandler = MobileGestureHandler( - context = requireContext(), - playerView = videoView, - skipIndicator = skipIndicator, - speedOverlay = speedOverlay, - ) - } - } else { - videoView.gestureHandler?.release() - videoView.gestureHandler = null - } } @OptIn(UnstableApi::class) @@ -705,15 +678,12 @@ abstract class PlaybackFragment( @OptIn(UnstableApi::class) override fun onStop() { Log.v(TAG, "onStop") - videoView.gestureHandler?.release() releasePlayer() keepScreenOn(false) super.onStop() } override fun onDestroyView() { - videoView.gestureHandler?.release() - videoView.gestureHandler = null super.onDestroyView() controllerVisibilityListener.removeAllListeners() videoView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?) diff --git a/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt b/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt index 13042a016..20db29b0f 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/playback/StashPlayerView.kt @@ -3,7 +3,6 @@ package com.github.damontecres.stashapp.playback import android.content.Context import android.util.AttributeSet import android.view.KeyEvent -import android.view.MotionEvent import androidx.annotation.OptIn import androidx.fragment.app.Fragment import androidx.fragment.app.findFragment @@ -30,8 +29,6 @@ class StashPlayerView( var skipIndicator: SkipIndicator? = null - var gestureHandler: MobileGestureHandler? = null - @OptIn(UnstableApi::class) override fun dispatchKeyEvent(event: KeyEvent): Boolean { val keyCode = event.keyCode @@ -92,14 +89,6 @@ class StashPlayerView( return super.dispatchKeyEvent(event) } - override fun dispatchTouchEvent(event: MotionEvent): Boolean { - val handler = gestureHandler - if (handler != null && useController) { - if (handler.onTouchEvent(event)) return true - } - return super.dispatchTouchEvent(event) - } - companion object { const val TAG = "StashPlayerView" } diff --git a/app/src/main/res/layout/video_playback.xml b/app/src/main/res/layout/video_playback.xml index c470b763f..e4d835e5b 100644 --- a/app/src/main/res/layout/video_playback.xml +++ b/app/src/main/res/layout/video_playback.xml @@ -26,18 +26,6 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - - - Date: Wed, 25 Mar 2026 03:54:09 +0000 Subject: [PATCH 08/10] Fix gesture bugs and refactor API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix return@detectTapGestures in onLongPress else branch; center long-press was aborting the entire gesture detection coroutine, breaking all subsequent taps/swipes — changed to return@onLongPress - Use pointerInput(zoomFactor > 1f) instead of pointerInput(zoomFactor) so the drag coroutine only restarts on zoom state transitions, not on every incremental zoom change - Remove scaledModifier parameter from rememberMobileGestureModifier; caller chains with scaledModifier.then(...) — cleaner API boundary - Add ControllerViewState.toggleControls() and use it everywhere to eliminate the duplicated show/hide conditional Co-Authored-By: Claude Sonnet 4.6 --- .../playback/MobileGestureModifier.kt | 19 ++++++++----------- .../ui/components/playback/PlaybackOverlay.kt | 4 ++++ .../playback/PlaybackPageContent.kt | 17 +++++++---------- 3 files changed, 19 insertions(+), 21 deletions(-) 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 index 72e60053c..3735bcd69 100644 --- 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 @@ -37,7 +37,6 @@ private const val GESTURE_FAST_SPEED = 2.0f */ @Composable fun rememberMobileGestureModifier( - scaledModifier: Modifier, player: Player, controllerViewState: ControllerViewState, updateSkipIndicator: (Long) -> Unit, @@ -69,7 +68,7 @@ fun rememberMobileGestureModifier( } } - return scaledModifier + return Modifier .onSizeChanged { surfaceWidth = it.width; surfaceHeight = it.height } .graphicsLayer { scaleX = zoomFactor @@ -87,11 +86,7 @@ fun rememberMobileGestureModifier( } }, onTap = { - if (controllerViewState.controlsVisible) { - controllerViewState.hideControls() - } else { - controllerViewState.showControls() - } + controllerViewState.toggleControls() }, onDoubleTap = { offset -> when { @@ -118,14 +113,16 @@ fun rememberMobileGestureModifier( val speed = when { offset.x < size.width / 3f -> GESTURE_SLOW_SPEED offset.x > size.width * 2f / 3f -> GESTURE_FAST_SPEED - else -> return@detectTapGestures + else -> null + } + if (speed != null) { + player.setPlaybackParameters(PlaybackParameters(speed)) + gestureSpeedActive = true } - player.setPlaybackParameters(PlaybackParameters(speed)) - gestureSpeedActive = true }, ) } - .pointerInput(zoomFactor) { + .pointerInput(zoomFactor > 1f) { if (zoomFactor <= 1f) { var dragX = 0f detectDragGestures( 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 3fa6a3700..406b5f898 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 @@ -785,11 +785,12 @@ fun PlaybackPageContent( PlayerSurface( player = player, surfaceType = SURFACE_TYPE_TEXTURE_VIEW, - modifier = rememberMobileGestureModifier( - scaledModifier = scaledModifier, - player = player, - controllerViewState = controllerViewState, - updateSkipIndicator = updateSkipIndicator, + modifier = scaledModifier.then( + rememberMobileGestureModifier( + player = player, + controllerViewState = controllerViewState, + updateSkipIndicator = updateSkipIndicator, + ), ), ) } else { @@ -802,11 +803,7 @@ fun PlaybackPageContent( indication = null, interactionSource = null, ) { - if (controllerViewState.controlsVisible) { - controllerViewState.hideControls() - } else { - controllerViewState.showControls() - } + controllerViewState.toggleControls() }, ) } From 3fecbaf12b1a9aa01a8d837222bc211dfa02ae22 Mon Sep 17 00:00:00 2001 From: mobile-alt Date: Wed, 25 Mar 2026 04:04:12 +0000 Subject: [PATCH 09/10] Fix pan speed when zoomed Multiply pan offset by zoomFactor so finger movement maps correctly to screen position at any zoom level. Co-Authored-By: Claude Sonnet 4.6 --- .../stashapp/ui/components/playback/MobileGestureModifier.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 3735bcd69..3dcbfde83 100644 --- 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 @@ -60,8 +60,8 @@ fun rememberMobileGestureModifier( if (zoomFactor > 1f) { val maxX = surfaceWidth * (zoomFactor - 1f) / 2f val maxY = surfaceHeight * (zoomFactor - 1f) / 2f - panX = (panX + offsetChange.x).coerceIn(-maxX, maxX) - panY = (panY + offsetChange.y).coerceIn(-maxY, maxY) + panX = (panX + offsetChange.x * zoomFactor).coerceIn(-maxX, maxX) + panY = (panY + offsetChange.y * zoomFactor).coerceIn(-maxY, maxY) } else { panX = 0f panY = 0f From 6948b8cb903d1fd4b843c451d11a2af13033f690 Mon Sep 17 00:00:00 2001 From: mobile-alt Date: Wed, 25 Mar 2026 04:12:58 +0000 Subject: [PATCH 10/10] Fix swipe-to-next/prev gesture reliability Replaced detectDragGestures with a manual swipe detector using PointerEventPass.Initial to observe move events before transformable() consumes them in the Main pass, which was causing intermittent gesture cancellation. Also uses awaitFirstDown(requireUnconsumed=false) so detectTapGestures' down.consume() doesn't block swipe detection. Added abs(dragX) > abs(dragY) guard to prevent vertical gestures from accidentally triggering next/prev. Co-Authored-By: Claude Sonnet 4.6 --- .../playback/MobileGestureModifier.kt | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) 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 index 3dcbfde83..67a64a52c 100644 --- 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 @@ -1,6 +1,7 @@ package com.github.damontecres.stashapp.ui.components.playback -import androidx.compose.foundation.gestures.detectDragGestures +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 @@ -14,10 +15,12 @@ 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 @@ -122,23 +125,29 @@ fun rememberMobileGestureModifier( }, ) } + // 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) { - var dragX = 0f - detectDragGestures( - onDragStart = { dragX = 0f }, - onDragCancel = { dragX = 0f }, - onDragEnd = { - if (dragX < -SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasNextMediaItem()) { + 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 > SWIPE_NEXT_PREV_THRESHOLD_PX && player.hasPreviousMediaItem()) { + } else if (dragX > 0 && player.hasPreviousMediaItem()) { player.seekToPrevious() } - dragX = 0f - }, - ) { change, dragAmount -> - dragX += dragAmount.x - change.consume() + } } } }