From 0eac127c6d4056da19694b8c258a0bfaff5f94fd Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Tue, 20 Oct 2020 13:58:31 +0200 Subject: [PATCH 1/2] Rename PlayerActivity and references to Fragment --- app/src/main/AndroidManifest.xml | 2 +- .../main/java/org/jellyfin/mobile/bridge/NativePlayer.kt | 4 ++-- .../main/java/org/jellyfin/mobile/player/PlaybackMenus.kt | 6 +++--- .../player/{PlayerActivity.kt => PlayerFragment.kt} | 8 ++++---- .../jellyfin/mobile/player/PlayerNotificationHelper.kt | 2 +- .../layout/{activity_player.xml => fragment_player.xml} | 0 6 files changed, 11 insertions(+), 11 deletions(-) rename app/src/main/java/org/jellyfin/mobile/player/{PlayerActivity.kt => PlayerFragment.kt} (98%) rename app/src/main/res/layout/{activity_player.xml => fragment_player.xml} (100%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9b6d5aaa8..d6105ed0e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,7 +47,7 @@ Date: Tue, 20 Oct 2020 17:50:27 +0200 Subject: [PATCH 2/2] Convert PlayerActivity to Fragment --- app/src/main/AndroidManifest.xml | 12 +- .../org/jellyfin/mobile/ApplicationModule.kt | 2 + .../java/org/jellyfin/mobile/MainActivity.kt | 27 ++- .../jellyfin/mobile/bridge/NativeInterface.kt | 2 +- .../jellyfin/mobile/bridge/NativePlayer.kt | 20 +- .../mobile/fragment/ConnectFragment.kt | 6 +- .../mobile/fragment/WebViewFragment.kt | 19 +- .../jellyfin/mobile/player/PlaybackMenus.kt | 42 ++-- .../jellyfin/mobile/player/PlayerFragment.kt | 179 +++++++++++------- .../mobile/player/PlayerNotificationHelper.kt | 3 +- .../player/source/MediaSourceManager.kt | 11 +- .../org/jellyfin/mobile/utils/LocaleUtils.kt | 4 +- .../mobile/utils/SmartOrientationListener.kt | 5 +- .../org/jellyfin/mobile/utils/UIExtensions.kt | 25 +-- app/src/main/res/layout/activity_main.xml | 1 - app/src/main/res/layout/fragment_connect.xml | 2 +- app/src/main/res/layout/fragment_player.xml | 3 +- app/src/main/res/layout/fragment_settings.xml | 2 +- app/src/main/res/layout/fragment_webview.xml | 2 +- .../styles.xml | 4 +- app/src/main/res/values-v29/styles.xml | 5 +- app/src/main/res/values/styles.xml | 15 +- 22 files changed, 214 insertions(+), 177 deletions(-) rename app/src/main/res/{values-land-v28 => values-v28}/styles.xml (56%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6105ed0e..1b1d4906a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ + @@ -39,20 +40,15 @@ + android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden|uiMode" + android:launchMode="singleTask" + android:supportsPictureInPicture="true"> - - - if (url != null) { - replaceFragment() - } else { - replaceFragment() + with(supportFragmentManager) { + if (url != null) { + replaceFragment() + } else { + replaceFragment() + } } } @@ -80,6 +83,14 @@ class MainActivity : AppCompatActivity() { return true } + override fun onUserLeaveHint() { + for (fragment in supportFragmentManager.fragments) { + if (fragment is PlayerFragment && fragment.isVisible) { + fragment.onUserLeaveHint() + } + } + } + override fun onStop() { super.onStop() orientationListener.disable() diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt index 7685011f1..a88c635d4 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt @@ -150,7 +150,7 @@ class NativeInterface(private val fragment: WebViewFragment) : KoinComponent { @JavascriptInterface fun openClientSettings() { - fragment.requireActivity().addFragment() + fragment.parentFragmentManager.addFragment() } @JavascriptInterface diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt index abc3b4f39..97e827a3d 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt @@ -1,21 +1,23 @@ package org.jellyfin.mobile.bridge -import android.content.Context -import android.content.Intent +import android.os.Bundle import android.webkit.JavascriptInterface +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.add import kotlinx.coroutines.channels.Channel import org.jellyfin.mobile.AppPreferences import org.jellyfin.mobile.PLAYER_EVENT_CHANNEL +import org.jellyfin.mobile.R import org.jellyfin.mobile.player.ExoPlayerFormats -import org.jellyfin.mobile.player.PlayerFragment import org.jellyfin.mobile.player.PlayerEvent +import org.jellyfin.mobile.player.PlayerFragment import org.jellyfin.mobile.settings.VideoPlayerType import org.jellyfin.mobile.utils.Constants import org.koin.core.KoinComponent import org.koin.core.inject import org.koin.core.qualifier.named -class NativePlayer(private val context: Context) : KoinComponent { +class NativePlayer(private val fragmentManager: FragmentManager) : KoinComponent { private val appPreferences: AppPreferences by inject() private val playerEventChannel: Channel by inject(named(PLAYER_EVENT_CHANNEL)) @@ -28,11 +30,13 @@ class NativePlayer(private val context: Context) : KoinComponent { @JavascriptInterface fun loadPlayer(args: String) { - val playerIntent = Intent(context, PlayerFragment::class.java).apply { - action = Constants.ACTION_PLAY_MEDIA - putExtra(Constants.EXTRA_MEDIA_SOURCE_ITEM, args) + val fragmentArgs = Bundle().apply { + putString(Constants.EXTRA_MEDIA_SOURCE_ITEM, args) } - context.startActivity(playerIntent) + fragmentManager.beginTransaction().apply { + add(R.id.fragment_container, args = fragmentArgs) + addToBackStack(null) + }.commit() } @JavascriptInterface diff --git a/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt b/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt index 052e6ca3d..ccc647700 100644 --- a/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt @@ -111,9 +111,9 @@ class ConnectFragment : Fragment() { if (httpUrl != null) { appPreferences.instanceUrl = httpUrl.toString() clearServerList() - with(requireActivity()) { - if (supportFragmentManager.backStackEntryCount > 0) - supportFragmentManager.popBackStack() + with(parentFragmentManager) { + if (backStackEntryCount > 0) + popBackStack() replaceFragment() } } diff --git a/app/src/main/java/org/jellyfin/mobile/fragment/WebViewFragment.kt b/app/src/main/java/org/jellyfin/mobile/fragment/WebViewFragment.kt index 53b773115..0a462639c 100644 --- a/app/src/main/java/org/jellyfin/mobile/fragment/WebViewFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/fragment/WebViewFragment.kt @@ -178,7 +178,7 @@ class WebViewFragment : Fragment() { domStorageEnabled = true } addJavascriptInterface(NativeInterface(this@WebViewFragment), "NativeInterface") - addJavascriptInterface(NativePlayer(context), "NativePlayer") + addJavascriptInterface(NativePlayer(parentFragmentManager), "NativePlayer") addJavascriptInterface(externalPlayer, "ExternalPlayer") loadUrl(requireNotNull(appPreferences.instanceUrl) { "Server url has not been set!" }) @@ -190,16 +190,15 @@ class WebViewFragment : Fragment() { } fun onSelectServer(error: Boolean = false) { - activity?.run { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - if (error) { - val extras = Bundle().apply { - putBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR, true) - } - replaceFragment(extras) - } else { - addFragment() + val activity = activity + if (activity != null && activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + if (error) { + val extras = Bundle().apply { + putBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR, true) } + parentFragmentManager.replaceFragment(extras) + } else { + parentFragmentManager.addFragment() } } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt index 4458326cd..9036a9381 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt @@ -10,8 +10,8 @@ import androidx.core.view.get import androidx.core.view.isVisible import androidx.core.view.size import org.jellyfin.mobile.R -import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding +import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.player.source.ExoPlayerTracksGroup import org.jellyfin.mobile.player.source.JellyfinMediaSource @@ -19,10 +19,11 @@ import org.jellyfin.mobile.player.source.JellyfinMediaSource * Provides a menu UI for audio, subtitle and video stream selection */ class PlaybackMenus( - private val activity: PlayerFragment, + private val fragment: PlayerFragment, private val playerBinding: FragmentPlayerBinding, private val playerControlsBinding: ExoPlayerControlViewBinding ) : PopupMenu.OnDismissListener { + private val context = playerBinding.root.context private val lockScreenButton: View by playerControlsBinding::lockScreenButton private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton private val subtitlesButton: View by playerControlsBinding::subtitlesButton @@ -33,21 +34,21 @@ class PlaybackMenus( init { lockScreenButton.setOnClickListener { - activity.lockScreen() + fragment.lockScreen() } audioStreamsButton.setOnClickListener { - activity.suppressControllerAutoHide(true) + fragment.suppressControllerAutoHide(true) audioStreamsMenu.show() } subtitlesButton.setOnClickListener { - activity.suppressControllerAutoHide(true) + fragment.suppressControllerAutoHide(true) subtitlesMenu.show() } infoButton.setOnClickListener { playbackInfo.isVisible = !playbackInfo.isVisible } playbackInfo.setOnClickListener { - playbackInfo.isVisible = false + dismissPlaybackInfo() } } @@ -55,22 +56,22 @@ class PlaybackMenus( buildMenuItems(subtitlesMenu.menu, SUBTITLES_MENU_GROUP, item.subtitleTracksGroup, true) buildMenuItems(audioStreamsMenu.menu, AUDIO_MENU_GROUP, item.audioTracksGroup) - val playMethod = activity.getString(R.string.playback_info_play_method, item.playMethod) - val transcodingInfo = activity.getString(R.string.playback_info_transcoding, item.isTranscoding) + val playMethod = context.getString(R.string.playback_info_play_method, item.playMethod) + val transcodingInfo = context.getString(R.string.playback_info_transcoding, item.isTranscoding) val videoTracksInfo = item.videoTracksGroup.tracks.run { joinToString( "\n", - "${activity.getString(R.string.playback_info_video_streams)}:\n", + "${fragment.getString(R.string.playback_info_video_streams)}:\n", limit = 3, - truncated = activity.getString(R.string.playback_info_and_x_more, size - 3) + truncated = fragment.getString(R.string.playback_info_and_x_more, size - 3) ) { "- ${it.title}" } } val audioTracksInfo = item.audioTracksGroup.tracks.run { joinToString( "\n", - "${activity.getString(R.string.playback_info_audio_streams)}:\n", + "${fragment.getString(R.string.playback_info_audio_streams)}:\n", limit = 5, - truncated = activity.getString(R.string.playback_info_and_x_more, size - 3) + truncated = fragment.getString(R.string.playback_info_and_x_more, size - 3) ) { "- ${it.title} (${it.language})" } } playbackInfo.text = listOf( @@ -81,9 +82,9 @@ class PlaybackMenus( ).joinToString("\n\n") } - private fun createSubtitlesMenu() = PopupMenu(activity, subtitlesButton).apply { + private fun createSubtitlesMenu() = PopupMenu(context, subtitlesButton).apply { setOnMenuItemClickListener { clickedItem -> - activity.onSubtitleSelected(clickedItem.itemId).also { success -> + fragment.onSubtitleSelected(clickedItem.itemId).also { success -> if (success) { menu.forEach { it.isChecked = false } clickedItem.isChecked = true @@ -93,9 +94,9 @@ class PlaybackMenus( setOnDismissListener(this@PlaybackMenus) } - private fun createAudioStreamsMenu() = PopupMenu(activity, audioStreamsButton).apply { + private fun createAudioStreamsMenu() = PopupMenu(context, audioStreamsButton).apply { setOnMenuItemClickListener { clickedItem: MenuItem -> - activity.onAudioTrackSelected(clickedItem.itemId).also { success -> + fragment.onAudioTrackSelected(clickedItem.itemId).also { success -> if (success) { menu.forEach { it.isChecked = false } clickedItem.isChecked = true @@ -107,7 +108,7 @@ class PlaybackMenus( private fun buildMenuItems(menu: Menu, groupId: Int, tracksGroup: ExoPlayerTracksGroup<*>, showNone: Boolean = false) { menu.clear() - if (showNone) menu.add(groupId, -1, Menu.NONE, activity.getString(R.string.menu_item_none)) + if (showNone) menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none)) tracksGroup.tracks.forEachIndexed { index, track -> menu.add(groupId, index, Menu.NONE, track.title) } @@ -127,9 +128,12 @@ class PlaybackMenus( } } + fun dismissPlaybackInfo() { + playbackInfo.isVisible = false + } + override fun onDismiss(menu: PopupMenu) { - activity.restoreFullscreenState() - activity.suppressControllerAutoHide(false) + fragment.suppressControllerAutoHide(false) } companion object { diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt index 0b947e275..d1f218836 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt @@ -2,22 +2,23 @@ package org.jellyfin.mobile.player import android.annotation.SuppressLint import android.app.PictureInPictureParams -import android.content.Context -import android.content.Intent import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.graphics.Color import android.media.AudioManager import android.os.Build import android.os.Bundle import android.provider.Settings.System import android.view.* import android.widget.* -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.getSystemService +import androidx.core.content.withStyledAttributes import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.core.view.postDelayed import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.PlayerView @@ -30,13 +31,15 @@ import org.jellyfin.mobile.utils.Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS import org.jellyfin.mobile.utils.Constants.DEFAULT_SEEK_TIME_MS import org.koin.android.ext.android.inject +import timber.log.Timber import kotlin.math.abs -class PlayerFragment : AppCompatActivity() { +class PlayerFragment : Fragment() { private val appPreferences: AppPreferences by inject() private val viewModel: PlayerViewModel by viewModels() - private lateinit var playerBinding: FragmentPlayerBinding + private var _playerBinding: FragmentPlayerBinding? = null + private val playerBinding: FragmentPlayerBinding get() = _playerBinding!! private val playerView: PlayerView get() = playerBinding.playerView private val playerOverlay: View get() = playerBinding.playerOverlay private val loadingIndicator: View get() = playerBinding.loadingIndicator @@ -44,15 +47,17 @@ class PlayerFragment : AppCompatActivity() { private val gestureIndicatorOverlayLayout: LinearLayout get() = playerBinding.gestureOverlayLayout private val gestureIndicatorOverlayImage: ImageView get() = playerBinding.gestureOverlayImage private val gestureIndicatorOverlayProgress: ProgressBar get() = playerBinding.gestureOverlayProgress - private lateinit var playerControlsBinding: ExoPlayerControlViewBinding + private var _playerControlsBinding: ExoPlayerControlViewBinding? = null + private val playerControlsBinding: ExoPlayerControlViewBinding get() = _playerControlsBinding!! private val playerControlsView: View get() = playerControlsBinding.root private val titleTextView: TextView get() = playerControlsBinding.trackTitle private val fullscreenSwitcher: ImageButton get() = playerControlsBinding.fullscreenSwitcher - private lateinit var playbackMenus: PlaybackMenus - private val audioManager: AudioManager by lazy { (getSystemService(Context.AUDIO_SERVICE) as AudioManager) } + private var playbackMenus: PlaybackMenus? = null + private val audioManager: AudioManager by lazy { requireContext().getSystemService()!! } - private val swipeGesturesEnabled - get() = appPreferences.exoPlayerAllowSwipeGestures + private var isZoomEnabled = false + + private val swipeGesturesEnabled by appPreferences::exoPlayerAllowSwipeGestures /** * Tracks a value during a swipe gesture (between multiple onScroll calls). @@ -69,7 +74,7 @@ class PlayerFragment : AppCompatActivity() { * 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(this) } + private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(requireActivity()) } /** * Runnable that hides the unlock screen button, used by [peekUnlockButton] @@ -92,48 +97,57 @@ class PlayerFragment : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - playerBinding = FragmentPlayerBinding.inflate(layoutInflater) - setContentView(playerBinding.root) - playerControlsBinding = ExoPlayerControlViewBinding.bind(findViewById(R.id.player_controls)) - // Handle system window insets - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> - playerControlsView.updatePadding(left = insets.systemWindowInsetLeft, right = insets.systemWindowInsetRight) - playerOverlay.updatePadding(left = insets.systemWindowInsetLeft, right = insets.systemWindowInsetRight) - insets + // Set orientation to landscape and enable fullscreen initially, set status bar color + with(requireActivity()) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + enableFullscreen() + window.statusBarColor = Color.BLACK } // Observe ViewModel viewModel.player.observe(this) { player -> playerView.player = player - if (player == null) finish() + if (player == null) parentFragmentManager.popBackStack() } viewModel.playerState.observe(this) { playerState -> val isPlaying = viewModel.playerOrNull?.isPlaying == true + val window = requireActivity().window if (isPlaying) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } if (playerState == Player.STATE_ENDED) { - finish() + parentFragmentManager.popBackStack() return@observe } loadingIndicator.isVisible = playerState == Player.STATE_BUFFERING } viewModel.mediaSourceManager.jellyfinMediaSource.observe(this) { jellyfinMediaSource -> - playbackMenus.onItemChanged(jellyfinMediaSource) titleTextView.text = jellyfinMediaSource.title + playbackMenus?.onItemChanged(jellyfinMediaSource) } - // Disable controller in PiP - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPictureInPictureMode) { - playerView.useController = false - } + // Handle intent + viewModel.mediaSourceManager.handleArguments(requireArguments()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _playerBinding = FragmentPlayerBinding.inflate(layoutInflater) + _playerControlsBinding = ExoPlayerControlViewBinding.bind(playerBinding.root.findViewById(R.id.player_controls)) + return playerBinding.root + } - // Handle current orientation and update fullscreen state - restoreFullscreenState() - setupFullscreenSwitcher() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Handle system window insets + ViewCompat.setOnApplyWindowInsetsListener(playerBinding.root) { _, insets -> + playerControlsView.updatePadding(left = insets.systemWindowInsetLeft, right = insets.systemWindowInsetRight) + playerOverlay.updatePadding(left = insets.systemWindowInsetLeft, right = insets.systemWindowInsetRight) + insets + } // Create playback menus playbackMenus = PlaybackMenus(this, playerBinding, playerControlsBinding) @@ -144,21 +158,20 @@ class PlayerFragment : AppCompatActivity() { // Setup gesture handling setupGestureDetector() + // Handle fullscreen switcher + fullscreenSwitcher.setOnClickListener { + val current = resources.configuration.orientation + requireActivity().requestedOrientation = when (current) { + Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } + // Handle unlock action unlockScreenButton.setOnClickListener { unlockScreenButton.isVisible = false unlockScreen() } - - // Handle intent - viewModel.mediaSourceManager.handleIntent(intent) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - when (intent.action) { - Constants.ACTION_PLAY_MEDIA -> viewModel.mediaSourceManager.handleIntent(intent, true) - } } override fun onStart() { @@ -169,16 +182,16 @@ class PlayerFragment : AppCompatActivity() { fun lockScreen() { playerView.useController = false orientationListener.disable() - lockOrientation() + requireActivity().lockOrientation() peekUnlockButton() } private fun unlockScreen() { - if (isAutoRotateOn()) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + if (requireActivity().isAutoRotateOn()) { + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } orientationListener.enable() - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isInPictureInPictureMode)) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !requireActivity().isInPictureInPictureMode)) { playerView.useController = true playerView.apply { if (!isControllerVisible) showController() @@ -186,24 +199,20 @@ class PlayerFragment : AppCompatActivity() { } } - fun restoreFullscreenState() { - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - if (isLandscape) enableFullscreen() else disableFullscreen() - } - - private fun setupFullscreenSwitcher() { + /** + * Handle current orientation and update fullscreen state and switcher icon + */ + private fun updateFullscreenStateAndSwitcher(configuration: Configuration) { + if (isLandscape(configuration)) requireActivity().enableFullscreen() else requireActivity().disableFullscreen() val fullscreenDrawable = when { - !isFullscreen() -> R.drawable.ic_fullscreen_enter_white_32dp + !requireActivity().isFullscreen() -> R.drawable.ic_fullscreen_enter_white_32dp else -> R.drawable.ic_fullscreen_exit_white_32dp } fullscreenSwitcher.setImageResource(fullscreenDrawable) - fullscreenSwitcher.setOnClickListener { - val current = resources.configuration.orientation - requestedOrientation = when (current) { - Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - } + } + + private fun updateZoomMode(enabled: Boolean) { + playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT } /** @@ -222,14 +231,14 @@ class PlayerFragment : AppCompatActivity() { @SuppressLint("ClickableViewAccessibility") private fun setupGestureDetector() { // Handles taps when controls are locked - val unlockDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { + val unlockDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { peekUnlockButton() return true } }) // Handles double tap to seek and brightness/volume gestures - val gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { + val gestureDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onDoubleTap(e: MotionEvent): Boolean { val viewWidth = playerView.measuredWidth val viewHeight = playerView.measuredHeight @@ -298,11 +307,12 @@ class PlayerFragment : AppCompatActivity() { gestureIndicatorOverlayProgress.progress = toSet } else { // Swiping on the left, change brightness + val window = requireActivity().window val windowLayoutParams = window.attributes if (swipeGestureValueTracker == -1f) { swipeGestureValueTracker = windowLayoutParams.screenBrightness if (swipeGestureValueTracker < 0f) - swipeGestureValueTracker = System.getFloat(contentResolver, System.SCREEN_BRIGHTNESS) / 255 + swipeGestureValueTracker = System.getFloat(requireContext().contentResolver, System.SCREEN_BRIGHTNESS) / 255 } swipeGestureValueTracker += ratioChange @@ -324,13 +334,14 @@ class PlayerFragment : AppCompatActivity() { } }) // Handles scale/zoom gesture - val zoomGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.OnScaleGestureListener { - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true + val zoomGestureDetector = ScaleGestureDetector(requireContext(), object : ScaleGestureDetector.OnScaleGestureListener { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = isLandscape() override fun onScale(detector: ScaleGestureDetector): Boolean { val scaleFactor = detector.scaleFactor if (abs(scaleFactor - 1f) > 0.01f) { - playerView.resizeMode = if (scaleFactor > 1) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT + isZoomEnabled = scaleFactor > 1 + updateZoomMode(isZoomEnabled) } return true } @@ -340,6 +351,8 @@ class PlayerFragment : AppCompatActivity() { zoomGestureDetector.isQuickScaleEnabled = false playerView.setOnTouchListener { _, event -> + Timber.d("RW: ${playerBinding.root.width}, PW: ${(playerBinding.root.parent as? View)?.width}") + if (playerView.useController) { when (event.pointerCount) { 1 -> gestureDetector.onTouchEvent(event) @@ -360,6 +373,9 @@ class PlayerFragment : AppCompatActivity() { } } + fun isLandscape(configuration: Configuration = resources.configuration) = + configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + /** * @return true if the audio track was changed */ @@ -374,14 +390,24 @@ class PlayerFragment : AppCompatActivity() { return viewModel.mediaSourceManager.selectSubtitle(index) } - override fun onUserLeaveHint() { + fun onUserLeaveHint() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && viewModel.playerOrNull?.isPlaying == true) { - enterPictureInPictureMode(PictureInPictureParams.Builder().build()) + requireActivity().enterPictureInPictureMode(PictureInPictureParams.Builder().build()) } } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { playerView.useController = !isInPictureInPictureMode + if (isInPictureInPictureMode) { + playbackMenus?.dismissPlaybackInfo() + hideUnlockButtonAction.run() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + updateFullscreenStateAndSwitcher(newConfig) + updateZoomMode(isLandscape(newConfig) && isZoomEnabled) } override fun onStop() { @@ -389,9 +415,26 @@ class PlayerFragment : AppCompatActivity() { orientationListener.disable() } - override fun onDestroy() { + override fun onDestroyView() { + super.onDestroyView() // Detach player from PlayerView playerView.player = null + + // Set binding references to null + _playerBinding = null + _playerControlsBinding = null + playbackMenus = null + } + + override fun onDestroy() { super.onDestroy() + // Reset screen orientation, disable fullscreen and reset status bar color + with(requireActivity()) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + disableFullscreen() + withStyledAttributes(0, intArrayOf(R.attr.colorPrimaryDark)) { + window.statusBarColor = getColor(0, Color.BLACK) + } + } } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerNotificationHelper.kt index 051d425f3..38cbe2e9a 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerNotificationHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerNotificationHelper.kt @@ -24,6 +24,7 @@ import org.jellyfin.apiclient.model.dto.ImageOptions import org.jellyfin.apiclient.model.entities.ImageType.Primary import org.jellyfin.mobile.AppPreferences import org.jellyfin.mobile.BuildConfig +import org.jellyfin.mobile.MainActivity import org.jellyfin.mobile.R import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID @@ -124,7 +125,7 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom } private fun buildContentIntent(): PendingIntent { - val intent = Intent(context, PlayerFragment::class.java).apply { + val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT } return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceManager.kt b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceManager.kt index 9bdd28010..83d22a70c 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceManager.kt @@ -2,13 +2,12 @@ package org.jellyfin.mobile.player.source import android.app.Application import android.content.Context -import android.content.Intent import android.net.Uri +import android.os.Bundle import androidx.annotation.CheckResult import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.Format import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MergingMediaSource @@ -32,10 +31,10 @@ class MediaSourceManager(private val viewModel: PlayerViewModel) { val trackSelector = DefaultTrackSelector(viewModel.getApplication()) val eventLogger = EventLogger(trackSelector) - fun handleIntent(intent: Intent, replace: Boolean = false): Boolean { + fun handleArguments(bundle: Bundle, replace: Boolean = false): Boolean { val oldSource = _jellyfinMediaSource.value if (oldSource == null || replace) { - val newSource = createFromIntent(intent) ?: return false + val newSource = createFromBundle(bundle) ?: return false _jellyfinMediaSource.value = newSource // Keep current selections in the new item @@ -66,8 +65,8 @@ class MediaSourceManager(private val viewModel: PlayerViewModel) { companion object { @CheckResult - private fun createFromIntent(intent: Intent): JellyfinMediaSource? { - val mediaSourceItem = intent.extras?.getString(Constants.EXTRA_MEDIA_SOURCE_ITEM) ?: return null + private fun createFromBundle(bundle: Bundle): JellyfinMediaSource? { + val mediaSourceItem = bundle.getString(Constants.EXTRA_MEDIA_SOURCE_ITEM) ?: return null return try { JellyfinMediaSource(JSONObject(mediaSourceItem)) } catch (e: JSONException) { diff --git a/app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt index fd097a0ae..d38c986b3 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt @@ -11,7 +11,7 @@ import java.util.* import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -fun WebViewFragment.initLocale() = lifecycleScope.launch { +fun WebViewFragment.initLocale() = lifecycleScope.launchWhenCreated { // Try to set locale via user settings val userSettings = suspendCoroutine { continuation -> webView.evaluateJavascript("window.localStorage.getItem('${apiClient.currentUserId}-language')") { result -> @@ -19,7 +19,7 @@ fun WebViewFragment.initLocale() = lifecycleScope.launch { } } if (requireContext().setLocale(userSettings.unescapeJson())) - return@launch + return@launchWhenCreated // Fallback to device locale Timber.i("Couldn't acquire locale from config, keeping current") diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt b/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt index cdf826830..186aa1fae 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt @@ -11,12 +11,15 @@ import android.view.OrientationEventListener */ 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 && activity.isAutoRotateOn()) { + if (isAtTarget) { // Reset to unspecified orientation activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt index c8eefbd31..bc90c8de4 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt @@ -7,22 +7,14 @@ import android.content.Context import android.content.pm.ActivityInfo import android.graphics.Point import android.os.Bundle -import android.view.ContextThemeWrapper -import android.view.LayoutInflater -import android.view.Surface -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager +import android.view.* import android.widget.Toast import androidx.annotation.IdRes import androidx.annotation.StringRes import androidx.annotation.StyleRes import androidx.core.view.ViewCompat import androidx.core.view.updateMargins -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.add -import androidx.fragment.app.replace +import androidx.fragment.app.* import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineScope @@ -43,11 +35,6 @@ const val FULLSCREEN_FLAGS = View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION -@Suppress("DEPRECATION") -fun Activity.setStableLayoutFlags() { - window.decorView.systemUiVisibility = STABLE_LAYOUT_FLAGS -} - @Suppress("DEPRECATION") fun Activity.isFullscreen() = window.decorView.systemUiVisibility.hasFlag(FULLSCREEN_FLAGS) @@ -94,15 +81,15 @@ inline fun LifecycleOwner.runOnUiThread(noinline block: suspend CoroutineScope.( lifecycleScope.launch(Dispatchers.Main, block = block) } -inline fun FragmentActivity.addFragment() { - supportFragmentManager.beginTransaction().apply { +inline fun FragmentManager.addFragment() { + beginTransaction().apply { add(R.id.fragment_container) addToBackStack(null) }.commit() } -inline fun FragmentActivity.replaceFragment(args: Bundle? = null) { - supportFragmentManager.beginTransaction().replace(R.id.fragment_container, args = args).commit() +inline fun FragmentManager.replaceFragment(args: Bundle? = null) { + beginTransaction().replace(R.id.fragment_container, args = args).commit() } fun LayoutInflater.withThemedContext(context: Context, @StyleRes style: Int): LayoutInflater { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 9a168a331..1c925b76e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,7 +4,6 @@ android:id="@+id/root_view" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?android:windowBackground" tools:viewBindingIgnore="true"> + android:background="@color/theme_background"> + android:layout_height="match_parent" + android:background="@android:color/black"> + android:background="@color/theme_background"> + android:background="@color/theme_background" /> diff --git a/app/src/main/res/values-land-v28/styles.xml b/app/src/main/res/values-v28/styles.xml similarity index 56% rename from app/src/main/res/values-land-v28/styles.xml rename to app/src/main/res/values-v28/styles.xml index 31dd724ac..5a4da095d 100644 --- a/app/src/main/res/values-land-v28/styles.xml +++ b/app/src/main/res/values-v28/styles.xml @@ -1,8 +1,8 @@ - - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b269302d1..2a0ac2409 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,25 +1,14 @@ - - - - - - -