diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt index bac6ccd7..e7b9bda3 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt @@ -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) diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 0967eda6..139fdd22 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -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, playbackPosition: Duration): Duration? { val startPositions = chapters.map { c -> c.startPositionTicks.ticks } return startPositions.findLast { pos -> playbackPosition >= pos } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index 7849ee93..7784f069 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -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() diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt index 463e9b1a..bc831b21 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt @@ -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) + } + } } diff --git a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt index dae914fc..5b31384b 100644 --- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt index 47eec221..364f15c5 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt @@ -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" diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml index 38622cef..2fffe0fb 100644 --- a/app/src/main/res/layout/fragment_player.xml +++ b/app/src/main/res/layout/fragment_player.xml @@ -48,6 +48,56 @@ tools:progress="50" /> + + + + + + + + + + + Touch and hold the screen to temporarily speed up playback Background audio Allow playing videos in the background with audio-only + Horizontal gestures + Allow horizontal gesture control for video seeking Allow SSA/ASS subtitles in direct play Prevent transcoding and show subtitles with basic styling only. Advanced subtitle styling will not be available if enabled. Playback buffer size