mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-09-03 05:10:27 +03:00
Add horizontal seek gesture support with overlay feedback (#1895)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user