diff --git a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt
index c1f3bd99..3a3febf5 100644
--- a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt
+++ b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt
@@ -8,6 +8,7 @@ import android.graphics.Color
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
+import android.view.OrientationEventListener
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.SystemBarStyle
@@ -31,6 +32,7 @@ import org.jellyfin.mobile.utils.BackPressInterceptor
import org.jellyfin.mobile.utils.BluetoothPermissionHelper
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper
+import org.jellyfin.mobile.utils.SmartOrientationListener
import org.jellyfin.mobile.utils.extensions.replaceFragment
import org.jellyfin.mobile.utils.isWebViewSupported
import org.jellyfin.mobile.webapp.RemotePlayerService
@@ -59,6 +61,8 @@ class MainActivity : AppCompatActivity() {
}
}
+ private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(this) }
+
/**
* Passes back press events onto the currently visible [Fragment] if it implements the [BackPressInterceptor] interface.
*
@@ -141,6 +145,11 @@ class MainActivity : AppCompatActivity() {
chromecast.initializePlugin(this)
}
+ override fun onStart() {
+ super.onStart()
+ orientationListener.enable()
+ }
+
private fun handleServerState(state: ServerState) {
with(supportFragmentManager) {
val currentFragment = findFragmentById(R.id.fragment_container)
@@ -190,6 +199,11 @@ class MainActivity : AppCompatActivity() {
}
}
+ override fun onStop() {
+ super.onStop()
+ orientationListener.disable()
+ }
+
override fun onDestroy() {
unbindService(serviceConnection)
chromecast.destroy()
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 cbba99ad..0f33bc11 100644
--- a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
+++ b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
@@ -96,6 +96,9 @@ class AppPreferences(context: Context) {
val videoPlayerType: String
get() = sharedPreferences.getString(Constants.PREF_VIDEO_PLAYER_TYPE, VideoPlayerType.EXO_PLAYER)!!
+ val exoPlayerStartLandscapeVideoInLandscape: Boolean
+ get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE, false)
+
val exoPlayerAllowSwipeGestures: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES, true)
diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
index 284b2ef7..f26ee625 100644
--- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
+++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
@@ -2,6 +2,7 @@ package org.jellyfin.mobile.events
import android.content.ActivityNotFoundException
import android.content.Intent
+import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
@@ -47,9 +48,12 @@ class ActivityEventHandler(
is ActivityEvent.ChangeFullscreen -> {
val fullscreenHelper = PlayerFullscreenHelper(window)
if (event.isFullscreen) {
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
fullscreenHelper.enableFullscreen()
window.setBackgroundDrawable(null)
} else {
+ // Reset screen orientation
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
fullscreenHelper.disableFullscreen()
// Reset window background color
window.setBackgroundDrawableResource(R.color.theme_background)
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 e5acafd9..3d5487cc 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
@@ -2,6 +2,7 @@ package org.jellyfin.mobile.player.ui
import android.app.Activity
import android.app.PictureInPictureParams
+import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Rect
import android.os.Build
@@ -9,6 +10,7 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
+import android.view.OrientationEventListener
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
@@ -41,6 +43,7 @@ import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS
import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL
import org.jellyfin.mobile.utils.Constants.PIP_MIN_RATIONAL
+import org.jellyfin.mobile.utils.SmartOrientationListener
import org.jellyfin.mobile.utils.brightness
import org.jellyfin.mobile.utils.extensions.aspectRational
import org.jellyfin.mobile.utils.extensions.getParcelableCompat
@@ -76,6 +79,16 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
private val currentVideoStream: MediaStream?
get() = viewModel.mediaSourceOrNull?.selectedVideoStream
+ /**
+ * Listener that watches the current device orientation.
+ * It makes sure that the orientation sensor can still be used (if enabled)
+ * after toggling the orientation through the fullscreen button.
+ *
+ * If the requestedOrientation was reset directly after setting it in the fullscreenSwitcher click handler,
+ * the orientation would get reverted before the user had any chance to rotate the device to the desired position.
+ */
+ private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(requireActivity()) }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -106,6 +119,9 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
if (mediaSource.selectedVideoStream?.isLandscape == false) {
// For portrait videos, immediately enable fullscreen
playerFullscreenHelper.enableFullscreen()
+ } else if (appPreferences.exoPlayerStartLandscapeVideoInLandscape) {
+ // Auto-switch to landscape for landscape videos if enabled
+ requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
// Update title and player menus
@@ -193,7 +209,7 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
// Disable controller animations
playerView.setControllerAnimationEnabled(false)
- playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding)
+ playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)
// Handle fullscreen switcher
@@ -202,6 +218,11 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
}
}
+ override fun onStart() {
+ super.onStart()
+ orientationListener.enable()
+ }
+
override fun onResume() {
super.onResume()
@@ -238,10 +259,25 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
}
/**
- * Toggle fullscreen
+ * Toggle fullscreen.
+ *
+ * If playing a portrait video, this just hides the status and navigation bars.
+ * For landscape videos, additionally the screen gets rotated.
*/
private fun toggleFullscreen() {
- playerFullscreenHelper.toggleFullscreen()
+ val videoTrack = currentVideoStream
+ if (videoTrack == null || videoTrack.isLandscape) {
+ val current = resources.configuration.orientation
+ requireActivity().requestedOrientation = when (current) {
+ Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ }
+ // No need to call playerFullscreenHelper in this case,
+ // since the configuration change triggers updateFullscreenState,
+ // which does it for us.
+ } else {
+ playerFullscreenHelper.toggleFullscreen()
+ }
}
/**
@@ -377,6 +413,11 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
}
}
+ override fun onStop() {
+ super.onStop()
+ orientationListener.disable()
+ }
+
override fun onDestroyView() {
super.onDestroyView()
// Detach player from PlayerView
@@ -391,6 +432,8 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
override fun onDestroy() {
super.onDestroy()
with(requireActivity()) {
+ // Reset screen orientation
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
playerFullscreenHelper.disableFullscreen()
// Reset screen brightness
window.brightness = BRIGHTNESS_OVERRIDE_NONE
diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt
index c3aa1631..f10b061f 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt
@@ -1,15 +1,20 @@
package org.jellyfin.mobile.player.ui
+import android.content.pm.ActivityInfo
+import android.view.OrientationEventListener
import android.widget.ImageButton
import androidx.core.view.isVisible
import androidx.media3.ui.PlayerView
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.Constants
+import org.jellyfin.mobile.utils.extensions.lockOrientation
+import org.jellyfin.mobile.utils.isAutoRotateOn
class PlayerLockScreenHelper(
private val playerFragment: PlayerFragment,
private val playerBinding: FragmentPlayerBinding,
+ private val orientationListener: OrientationEventListener,
) {
private val playerView: PlayerView by playerBinding::playerView
private val unlockScreenButton: ImageButton by playerBinding::unlockScreenButton
@@ -30,12 +35,18 @@ class PlayerLockScreenHelper(
fun lockScreen() {
playerView.useController = false
+ orientationListener.disable()
+ playerFragment.requireActivity().lockOrientation()
peekUnlockButton()
}
private fun unlockScreen() {
hideUnlockButton()
val activity = playerFragment.requireActivity()
+ if (activity.isAutoRotateOn()) {
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ orientationListener.enable()
if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) {
playerView.useController = true
playerView.apply {
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 bd4e7402..12753cc4 100644
--- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
+++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
@@ -132,6 +132,10 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
externalPlayerChoicePreference.enabled = selection == VideoPlayerType.EXTERNAL_PLAYER
}
}
+ startLandscapeVideoInLandscapePreference = checkBox(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE) {
+ titleRes = R.string.pref_exoplayer_start_landscape_video_in_landscape
+ enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
+ }
swipeGesturesPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES) {
titleRes = R.string.pref_exoplayer_allow_brightness_volume_gesture
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
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 8a59a440..315e63a4 100644
--- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
+++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
@@ -32,6 +32,7 @@ object Constants {
const val PREF_DOWNLOAD_METHOD = "pref_download_method"
const val PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE = "pref_music_notification_always_dismissible"
const val PREF_VIDEO_PLAYER_TYPE = "pref_video_player_type"
+ const val PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE = "pref_exoplayer_start_landscape_video_in_landscape"
const val PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES = "pref_exoplayer_allow_swipe_gestures"
const val PREF_EXOPLAYER_ALLOW_PRESS_SPEED_UP = "pref_exoplayer_allow_press_speed_up"
const val PREF_EXOPLAYER_REMEMBER_BRIGHTNESS = "pref_exoplayer_remember_brightness"
diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt b/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt
new file mode 100644
index 00000000..ff679a9e
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt
@@ -0,0 +1,26 @@
+package org.jellyfin.mobile.utils
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+import android.view.OrientationEventListener
+
+/**
+ * Listener that watches the current device orientation.
+ * It makes sure that the orientation sensor can still be used (if enabled)
+ * after toggling the orientation manually.
+ */
+class SmartOrientationListener(private val activity: Activity) : OrientationEventListener(activity) {
+ override fun onOrientationChanged(orientation: Int) {
+ if (!activity.isAutoRotateOn()) return
+
+ val isAtTarget = when (activity.requestedOrientation) {
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT -> orientation in Constants.ORIENTATION_PORTRAIT_RANGE
+ ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE -> orientation in Constants.ORIENTATION_LANDSCAPE_RANGE
+ else -> false
+ }
+ if (isAtTarget) {
+ // Reset to unspecified orientation
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Activity.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Activity.kt
new file mode 100644
index 00000000..f7833fa0
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Activity.kt
@@ -0,0 +1,20 @@
+package org.jellyfin.mobile.utils.extensions
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+import android.graphics.Point
+import android.view.Surface
+
+@Suppress("DEPRECATION")
+fun Activity.lockOrientation() {
+ val display = windowManager.defaultDisplay
+ val size = Point().also(display::getSize)
+ val height = size.y
+ val width = size.x
+ requestedOrientation = when (display.rotation) {
+ Surface.ROTATION_90 -> if (width > height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
+ Surface.ROTATION_180 -> if (height > width) ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
+ Surface.ROTATION_270 -> if (width > height) ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ else -> if (height > width) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 02fc0efa..5c150722 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -101,6 +101,7 @@
The default HTML video player from the Web UI
Based on ExoPlayer, supports more video formats and codecs, and is more integrated into the OS
External video playback apps like MX Player and VLC
+ Start landscape mode videos in landscape orientation
Brightness and volume gestures
Remember display brightness
Hold to speed up