Add horizontal seek gesture support with overlay feedback (#1895)

This commit is contained in:
SJJ
2026-06-18 14:43:37 +02:00
committed by GitHub
parent 876e8acf4d
commit 122c1801d8
8 changed files with 238 additions and 10 deletions
@@ -121,6 +121,9 @@ class AppPreferences(context: Context) {
val exoPlayerAllowBackgroundAudio: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO, false)
val exoPlayerAllowHorizontalGesture: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_HORIZONTAL_GESTURE, true)
val exoPlayerDirectPlayAss: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS, false)
@@ -572,6 +572,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
playerOrNull?.seekToOffset(displayPreferences.skipForwardLength)
}
fun seekByOffset(offsetMs: Long) {
playerOrNull?.seekToOffset(offsetMs)
}
private fun getCurrentChapterStartPosition(chapters: List<ChapterInfo>, playbackPosition: Duration): Duration? {
val startPositions = chapters.map { c -> c.startPositionTicks.ticks }
return startPositions.findLast { pos -> playbackPosition >= pos }
@@ -288,6 +288,8 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
fun onFastForward() = viewModel.fastForward()
fun onSeekByOffset(offsetMs: Long) = viewModel.seekByOffset(offsetMs)
fun onPreviousChapter() = viewModel.previousChapter()
fun onNextChapter() = viewModel.nextChapter()
@@ -11,6 +11,7 @@ import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.getSystemService
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
@@ -24,6 +25,7 @@ import org.jellyfin.mobile.utils.brightness
import org.jellyfin.mobile.utils.dip
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.util.Locale
import kotlin.math.abs
class PlayerGestureHelper(
@@ -37,6 +39,11 @@ class PlayerGestureHelper(
private val gestureIndicatorOverlayLayout: LinearLayout by playerBinding::gestureOverlayLayout
private val gestureIndicatorOverlayImage: ImageView by playerBinding::gestureOverlayImage
private val gestureIndicatorOverlayProgress: ProgressBar by playerBinding::gestureOverlayProgress
private val seekOverlayLayout: LinearLayout by playerBinding::seekOverlayLayout
private val seekOverlayImage: ImageView by playerBinding::seekOverlayImage
private val seekOverlayText: TextView by playerBinding::seekOverlayText
private val seekPositionText: TextView by playerBinding::seekPositionText
private val seekOverlayProgress: ProgressBar by playerBinding::seekOverlayProgress
private var isOnPressingSpeedUp = false
init {
@@ -58,6 +65,26 @@ class PlayerGestureHelper(
*/
private var swipeGestureValueTracker = -1f
/**
* Tracks whether a horizontal swipe seek gesture is in progress.
*/
private var isHorizontalSeeking = false
/**
* Tracks accumulated seek time during horizontal swipe (in milliseconds).
*/
private var seekTimeAccumulator = 0L
/**
* Tracks the initial playback position when seek gesture started.
*/
private var seekStartPosition = 0L
/**
* Tracks total duration of current media.
*/
private var mediaDuration = 0L
/**
* Runnable that hides [playerView] controller
*/
@@ -72,6 +99,13 @@ class PlayerGestureHelper(
gestureIndicatorOverlayLayout.isVisible = false
}
/**
* Runnable that hides [seekOverlayLayout]
*/
private val hideSeekOverlayAction = Runnable {
seekOverlayLayout.isVisible = false
}
/**
* Handles taps when controls are locked
*/
@@ -145,22 +179,110 @@ class PlayerGestureHelper(
distanceX: Float,
distanceY: Float,
): Boolean {
if (!appPreferences.exoPlayerAllowSwipeGestures) {
return false
}
// Check whether swipe was started in excluded region
val exclusionSize = playerView.resources.dip(Constants.SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL)
// Check whether swipe was started in excluded region (vertical)
val exclusionSizeVertical = playerView.resources.dip(Constants.SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL)
if (
firstEvent == null ||
firstEvent.y < exclusionSize ||
firstEvent.y > playerView.height - exclusionSize
firstEvent.y < exclusionSizeVertical ||
firstEvent.y > playerView.height - exclusionSizeVertical
) {
return false
}
// Check whether swipe was oriented vertically
if (abs(distanceY / distanceX) < 2) {
// Check whether swipe was started in excluded region (horizontal) for horizontal gestures
val exclusionSizeHorizontal = playerView.resources.dip(Constants.SWIPE_GESTURE_EXCLUSION_SIZE_HORIZONTAL)
// Determine swipe direction based on distance ratio
val isVerticalSwipe = abs(distanceY / distanceX) >= 2
val isHorizontalSwipe = abs(distanceX / distanceY) >= 2
// Handle horizontal swipe for seek
if ((isHorizontalSwipe || isHorizontalSeeking) && appPreferences.exoPlayerAllowHorizontalGesture) {
// Check horizontal exclusion zones (edges of screen)
if (
firstEvent.x < exclusionSizeHorizontal ||
firstEvent.x > playerView.width - exclusionSizeHorizontal
) {
return false
}
// Initialize seek start position on first swipe
if (!isHorizontalSeeking) {
val player = playerView.player
if (player != null) {
seekStartPosition = player.currentPosition
mediaDuration = player.duration.coerceAtLeast(0)
}
}
isHorizontalSeeking = true
// Calculate seek time with non-linear acceleration
// The further you swipe, the faster the seek time increases
val baseSeekDeltaMs = (-distanceX * 1000 / Constants.HORIZONTAL_SWIPE_DISTANCE_PER_SECOND).toLong()
// Apply acceleration based on accumulated distance
// Use portrait acceleration (2x) for portrait mode, default for landscape
val accelerationFactor = if (fragment.isLandscape()) {
Constants.SEEK_ACCELERATION_FACTOR
} else {
Constants.SEEK_ACCELERATION_FACTOR_PORTRAIT
}
val currentSeekSeconds = abs(seekTimeAccumulator / 1000f)
val accelerationMultiplier = 1f + (currentSeekSeconds / 30f) * (accelerationFactor - 1f)
val acceleratedSeekDelta = (baseSeekDeltaMs * accelerationMultiplier).toLong()
seekTimeAccumulator += acceleratedSeekDelta
// Clamp the accumulated seek time to valid range
val minSeek = -seekStartPosition
val maxSeek = if (mediaDuration > 0) mediaDuration - seekStartPosition else Long.MAX_VALUE
// Allow seeking up to media duration (if known). Do not enforce an artificial MAX_SEEK_TIME_MS limit.
seekTimeAccumulator = seekTimeAccumulator.coerceIn(minSeek, maxSeek)
// Update the seek overlay with mm:ss format
val totalSeconds = abs(seekTimeAccumulator / 1000)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
val timeFormatted = String.format(Locale.US, "%02d:%02d", minutes, seconds)
val seekText = if (seekTimeAccumulator >= 0) "+$timeFormatted" else "-$timeFormatted"
seekOverlayText.text = seekText
// Update position text (current position / duration)
val targetPosition = (seekStartPosition + seekTimeAccumulator).coerceIn(0, mediaDuration)
seekPositionText.text = "${formatTime(targetPosition)} / ${formatTime(mediaDuration)}"
// Update progress bar
if (mediaDuration > 0) {
seekOverlayProgress.max = 1000
seekOverlayProgress.progress = (targetPosition * 1000 / mediaDuration).toInt()
}
// Set appropriate icon based on direction
val iconRes = if (seekTimeAccumulator >= 0) {
R.drawable.ic_fast_forward_black_32dp
} else {
R.drawable.ic_rewind_black_32dp
}
seekOverlayImage.setImageResource(iconRes)
seekOverlayLayout.isVisible = true
return true
} else if (isHorizontalSeeking && !appPreferences.exoPlayerAllowHorizontalGesture) {
// If horizontal gesture is disabled while a gesture was in progress, reset the state
isHorizontalSeeking = false
seekTimeAccumulator = 0L
seekStartPosition = 0L
mediaDuration = 0L
seekOverlayLayout.isVisible = false
}
if (!appPreferences.exoPlayerAllowSwipeGestures) {
return false
}
// Handle vertical swipe for brightness/volume (existing logic)
if (!isVerticalSwipe) {
return false
}
@@ -262,6 +384,23 @@ class PlayerGestureHelper(
onPressSpeedUp(false)
}
}
// Handle horizontal seek gesture completion
if (isHorizontalSeeking && seekTimeAccumulator != 0L) {
fragment.onSeekByOffset(seekTimeAccumulator)
seekOverlayLayout.apply {
removeCallbacks(hideSeekOverlayAction)
postDelayed(
hideSeekOverlayAction,
Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS.toLong(),
)
}
}
isHorizontalSeeking = false
seekTimeAccumulator = 0L
seekStartPosition = 0L
mediaDuration = 0L
// Hide gesture indicator after timeout, if shown
gestureIndicatorOverlayLayout.apply {
if (isVisible) {
@@ -285,4 +424,19 @@ class PlayerGestureHelper(
private fun updateZoomMode(enabled: Boolean) {
playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
}
/**
* Format time in milliseconds to mm:ss or h:mm:ss format
*/
private fun formatTime(timeMs: Long): String {
val totalSeconds = timeMs / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) {
String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.US, "%02d:%02d", minutes, seconds)
}
}
}
@@ -58,6 +58,7 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
private lateinit var pressSpeedUpPreference: CheckBoxPreference
private lateinit var rememberBrightnessPreference: Preference
private lateinit var backgroundAudioPreference: Preference
private lateinit var horizontalGesturePreference: Preference
private lateinit var directPlayAssPreference: Preference
private lateinit var networkBufferPreference: Preference
private lateinit var externalPlayerChoicePreference: Preference
@@ -125,6 +126,7 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
rememberBrightnessPreference.enabled = selection == VideoPlayerType.EXO_PLAYER && swipeGesturesPreference.checked
pressSpeedUpPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
backgroundAudioPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
horizontalGesturePreference.enabled = selection == VideoPlayerType.EXO_PLAYER
directPlayAssPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
networkBufferPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
externalPlayerChoicePreference.enabled = selection == VideoPlayerType.EXTERNAL_PLAYER
@@ -160,6 +162,12 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
summaryRes = R.string.pref_exoplayer_allow_background_audio_summary
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
}
horizontalGesturePreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_HORIZONTAL_GESTURE) {
titleRes = R.string.pref_exoplayer_allow_horizontal_gesture
summaryRes = R.string.pref_exoplayer_allow_horizontal_gesture_summary
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
defaultValue = true
}
directPlayAssPreference = checkBox(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS) {
titleRes = R.string.pref_exoplayer_direct_play_ass
summaryRes = R.string.pref_exoplayer_direct_play_ass_summary
@@ -38,6 +38,7 @@ object Constants {
const val PREF_EXOPLAYER_REMEMBER_BRIGHTNESS = "pref_exoplayer_remember_brightness"
const val PREF_EXOPLAYER_BRIGHTNESS = "pref_exoplayer_brightness"
const val PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO = "pref_exoplayer_allow_background_audio"
const val PREF_EXOPLAYER_ALLOW_HORIZONTAL_GESTURE = "pref_exoplayer_allow_horizontal_gesture"
const val PREF_EXOPLAYER_DIRECT_PLAY_ASS = "pref_exoplayer_direct_play_ass"
const val PREF_EXOPLAYER_NETWORK_BUFFER = "pref_exoplayer_network_buffer"
const val NETWORK_BUFFER_AUTO = "auto"
@@ -110,6 +111,10 @@ object Constants {
const val DEFAULT_CONTROLS_TIMEOUT_MS = 2500
const val SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL = 64
const val DEFAULT_CENTER_OVERLAY_TIMEOUT_MS = 250
const val SWIPE_GESTURE_EXCLUSION_SIZE_HORIZONTAL = 48
const val HORIZONTAL_SWIPE_DISTANCE_PER_SECOND = 20 // base pixels needed to swipe for 1 second seek
const val SEEK_ACCELERATION_FACTOR = 2.5f // acceleration factor for non-linear seek
const val SEEK_ACCELERATION_FACTOR_PORTRAIT = 5.0f
const val DISPLAY_PREFERENCES_ID_USER_SETTINGS = "usersettings"
const val DISPLAY_PREFERENCES_CLIENT_EMBY = "emby"
const val DISPLAY_PREFERENCES_SKIP_BACK_LENGTH = "skipBackLength"
@@ -48,6 +48,56 @@
tools:progress="50" />
</LinearLayout>
<LinearLayout
android:id="@+id/seek_overlay_layout"
android:layout_width="@dimen/exo_gesture_overlay_width"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/playback_info_background"
android:clickable="false"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/seek_overlay_image"
android:layout_width="@dimen/exo_gesture_overlay_image_size"
app:tint="@android:color/white"
android:layout_height="@dimen/exo_gesture_overlay_image_size"
android:layout_marginTop="8dp"
tools:srcCompat="@drawable/ic_fast_forward_black_32dp" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/seek_overlay_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold"
tools:text="+01:30" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/seek_position_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@android:color/darker_gray"
android:textSize="14sp"
tools:text="12:30 / 1:45:00" />
<ProgressBar
android:id="@+id/seek_overlay_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
tools:progress="30" />
</LinearLayout>
<ProgressBar
android:id="@+id/loading_indicator"
android:layout_width="64dp"
+2
View File
@@ -108,6 +108,8 @@
<string name="pref_exoplayer_allow_press_speed_up_summary">Touch and hold the screen to temporarily speed up playback</string>
<string name="pref_exoplayer_allow_background_audio">Background audio</string>
<string name="pref_exoplayer_allow_background_audio_summary">Allow playing videos in the background with audio-only</string>
<string name="pref_exoplayer_allow_horizontal_gesture">Horizontal gestures</string>
<string name="pref_exoplayer_allow_horizontal_gesture_summary">Allow horizontal gesture control for video seeking</string>
<string name="pref_exoplayer_direct_play_ass">Allow SSA/ASS subtitles in direct play</string>
<string name="pref_exoplayer_direct_play_ass_summary">Prevent transcoding and show subtitles with basic styling only. Advanced subtitle styling will not be available if enabled.</string>
<string name="pref_exoplayer_network_buffer">Playback buffer size</string>