From 4e08fb90e34d87137abee2cea5559e2cd4b79651 Mon Sep 17 00:00:00 2001 From: Vincent TE Date: Wed, 5 Feb 2025 10:17:54 +0100 Subject: [PATCH] fix: Use only one instance of MediaController --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 3 +- .../drive/ui/BasePreviewSliderFragment.kt | 136 ++++++++++++++---- .../preview/playback/PlaybackService.kt | 4 +- .../playback/PreviewPlaybackFragment.kt | 107 ++++++-------- 5 files changed, 156 insertions(+), 98 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index deb0e08a26..f34de389a8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ android { namespace 'com.infomaniak.drive' - compileSdk 34 + compileSdk 35 ndkVersion "25.2.9519653" @@ -142,7 +142,7 @@ dependencies { implementation 'androidx.viewpager2:viewpager2:1.1.0' implementation 'androidx.webkit:webkit:1.12.1' - def exoplayer_version = '1.4.1' + def exoplayer_version = '1.5.1' implementation "androidx.media3:media3-exoplayer:$exoplayer_version" implementation "androidx.media3:media3-ui:$exoplayer_version" implementation "androidx.media3:media3-datasource-okhttp:$exoplayer_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8eb30e96e0..2a763ea6d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -99,7 +99,8 @@ + android:supportsPictureInPicture="true" + android:configChanges="orientation|screenSize|layoutDirection|screenLayout|smallestScreenSize" /> ? = null + private var mediaController: MediaController? = null + // This is not protected, otherwise it won't build because PublicSharePreviewSliderFragment needs it public for the interface // it implements val drivePermissions: DrivePermissions = DrivePermissions() @@ -120,22 +137,61 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte userDrive = previewSliderViewModel.userDrive, ) + initViewPager() + + previewSliderViewModel.pdfIsDownloading.observe(viewLifecycleOwner) { isDownloading -> + if (!currentFile.isOnlyOfficePreview()) header.toggleOpenWithVisibility(isVisible = !isDownloading) + } + + mainViewModel.currentPreviewFileList.let { files -> + previewSliderAdapter.setFiles(ArrayList(files.values)) + val position = previewSliderAdapter.getPosition(currentFile) + runCatching { + viewPager.setCurrentItem(position, false) + }.onFailure { + Sentry.withScope { scope -> + scope.setExtra("currentFile", "id: ${currentFile.id}") + scope.setExtra("files.values", files.values.joinToString { "id: ${it.id}" }) + Sentry.captureException(it) + } + currentFile = files.values.first() + viewPager.setCurrentItem(0, false) + } + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + if (isInPictureInPictureMode) { + toggleBottomSheet(false) + } + } + + @OptIn(UnstableApi::class) + fun getMediaController(callback: (MediaController) -> Unit) { + if (mediaController == null) { + val context = requireContext() + val playbackSessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + mediaControllerFuture = MediaController.Builder(context, playbackSessionToken).buildAsync().apply { + addListener( + getRunnable(callback), + mainExecutor, + ) + } + } else { + callback(mediaController!!) + } + } + + private fun initViewPager() = with(binding) { viewPager.apply { adapter = previewSliderAdapter offscreenPageLimit = 1 registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + @RequiresApi(Build.VERSION_CODES.N) @OptIn(UnstableApi::class) override fun onPageSelected(position: Int) { - val selectedFragment = childFragmentManager.findFragmentByTag("f${previewSliderAdapter.getItemId(position)}") - - // Implementation of onFragmentUnselected to handle resume of media to the same position, only - // for PreviewVideoFragment. - childFragmentManager.fragments.filter { - it is PreviewPlaybackFragment && it != selectedFragment - }.forEach { unselectedFragment -> - (unselectedFragment as? PreviewPlaybackFragment)?.onFragmentUnselected() - } currentFile = previewSliderAdapter.getFile(position) previewSliderViewModel.currentPreview = currentFile @@ -147,28 +203,18 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte setPrintButtonVisibility(isGone = !currentFile.isPDF()) (bottomSheetView as? FileInfoActionsView)?.openWith?.isGone = isPublicShare updateBottomSheetWithCurrentFile() - } - }) - } - previewSliderViewModel.pdfIsDownloading.observe(viewLifecycleOwner) { isDownloading -> - if (!currentFile.isOnlyOfficePreview()) header.toggleOpenWithVisibility(isVisible = !isDownloading) - } + val selectedFragment = childFragmentManager.findFragmentByTag("f${previewSliderAdapter.getItemId(position)}") - mainViewModel.currentPreviewFileList.let { files -> - previewSliderAdapter.setFiles(ArrayList(files.values)) - val position = previewSliderAdapter.getPosition(currentFile) - runCatching { - viewPager.setCurrentItem(position, false) - }.onFailure { - Sentry.withScope { scope -> - scope.setExtra("currentFile", "id: ${currentFile.id}") - scope.setExtra("files.values", files.values.joinToString { "id: ${it.id}" }) - Sentry.captureException(it) + // Implementation of onFragmentUnselected to handle resume of media to the same position, only + // for PreviewPlaybackFragment. + childFragmentManager.fragments.filterIsInstance().forEach { fragment -> + if (fragment != selectedFragment) { + (fragment as? PreviewPlaybackFragment)?.onFragmentUnselected() + } + } } - currentFile = files.values.first() - viewPager.setCurrentItem(0, false) - } + }) } } @@ -181,8 +227,13 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte super.onPause() if (noPreviewList()) return previewSliderViewModel.currentPreview = currentFile + + if (mediaController?.isPlaying == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + pipParams?.let { requireActivity().enterPictureInPictureMode(it) } + } } + @RequiresApi(Build.VERSION_CODES.N) override fun onStop() { clearEdgeToEdge() super.onStop() @@ -200,9 +251,27 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte mainViewModel.currentPreviewFileList = LinkedHashMap() } + // Release Player + mediaController?.apply { + release() + mediaController = null + mediaControllerFuture?.let { MediaController.releaseFuture(it) } + mediaControllerFuture = null + } + super.onDestroy() } + private fun getPictureInPictureParams(): PictureInPictureParams? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PictureInPictureParams.Builder() + .setAspectRatio(Rational(16, 9)) + .build() + } else { + null + } + } + protected fun noPreviewList() = mainViewModel.currentPreviewFileList.isEmpty() protected open fun setBackActionHandlers() { @@ -231,6 +300,17 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte } } + @OptIn(UnstableApi::class) + private fun getRunnable(callback: (MediaController) -> Unit): Runnable { + return Runnable { + if (mediaController == null) { + mediaController = mediaControllerFuture?.get()?.apply { + callback(this) + } + } + } + } + private fun updateBottomSheetWithCurrentFile() = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { lifecycle.withResumed { when (val fileActionBottomSheet = bottomSheetView) { diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackService.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackService.kt index 94af8fe952..1c1dc47a56 100644 --- a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackService.kt +++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackService.kt @@ -46,9 +46,9 @@ class PlaybackService : MediaSessionService() { private val mediaSessionCallback = object : MediaSession.Callback { - // When the user returns from the PreviewVideoFragment, we want to stop + // When the user returns from the PreviewPlaybackFragment, we want to stop // the service because it does not make sense to have the media notification - // when the user willingly quits the PreviewVideoFragment. + // when the user willingly quits the PreviewPlaybackFragment. override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) { super.onDisconnected(session, controller) stopSelf() diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PreviewPlaybackFragment.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PreviewPlaybackFragment.kt index c2b4d5ae6d..d9058dca6d 100644 --- a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PreviewPlaybackFragment.kt @@ -17,30 +17,29 @@ */ package com.infomaniak.drive.ui.fileList.preview.playback -import android.content.ComponentName import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager -import androidx.core.content.ContextCompat +import androidx.annotation.RequiresApi import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken import androidx.media3.ui.PlayerView -import com.google.common.util.concurrent.ListenableFuture import com.infomaniak.drive.R import com.infomaniak.drive.data.api.ApiRoutes import com.infomaniak.drive.databinding.FragmentPreviewPlaybackBinding +import com.infomaniak.drive.ui.BasePreviewSliderFragment import com.infomaniak.drive.ui.BasePreviewSliderFragment.Companion.openWithClicked import com.infomaniak.drive.ui.BasePreviewSliderFragment.Companion.toggleFullscreen import com.infomaniak.drive.ui.fileList.preview.PreviewFragment +import com.infomaniak.drive.ui.fileList.preview.PreviewSliderFragment import com.infomaniak.drive.ui.fileList.preview.playback.PlayerListener.Companion.trackMediaPlayerEvent import com.infomaniak.drive.utils.IOFile @@ -50,14 +49,20 @@ open class PreviewPlaybackFragment : PreviewFragment() { private var _binding: FragmentPreviewPlaybackBinding? = null private val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView - private val mainExecutor by lazy { ContextCompat.getMainExecutor(requireContext()) } - - private var mediaControllerFuture: ListenableFuture? = null - private var mediaController: MediaController? = null - private var mediaPosition = 0L private val flagKeepScreenOn by lazy { WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON } + + private val offlineFile: IOFile? by lazy { + if (file.isOffline) { + file.getOfflineFile(requireContext(), previewSliderViewModel.userDrive.userId) + } else { + null + } + } + private val offlineIsComplete by lazy { isOfflineFileComplete(offlineFile) } + + @RequiresApi(Build.VERSION_CODES.N) private val playerListener = PlayerListener(activity, isPlayingChanged = { isPlaying -> if (isPlaying) { toggleFullscreen() @@ -107,77 +112,49 @@ open class PreviewPlaybackFragment : PreviewFragment() { } } + @RequiresApi(Build.VERSION_CODES.N) override fun onResume() { super.onResume() - if (!noCurrentFile() && (mediaController == null || mediaController?.currentPosition == 0L)) { - createPlayer() - } else if (mediaController?.isPlaying == false) { - mediaController?.seekTo(mediaPosition) - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onDestroy() { - mediaController?.let { - it.release() - it.removeListener(playerListener) - } - mediaController = null + (parentFragment as BasePreviewSliderFragment).getMediaController { mediaController -> + if (!mediaController.isPlaying) { + mediaController.removeListener(playerListener) + mediaController.addListener(playerListener) - mediaControllerFuture?.let { MediaController.releaseFuture(it) } - mediaControllerFuture = null - super.onDestroy() - } + mediaController.setMediaItem(getMediaItem(offlineFile, offlineIsComplete)) - fun onFragmentUnselected() { - mediaController?.pause() - mediaPosition = mediaController?.currentPosition ?: 0L - } + binding.playerView.player = mediaController + binding.playerView.controllerShowTimeoutMs = CONTROLLER_SHOW_TIMEOUT_MS + binding.playerView.controllerHideOnTouch = false - private fun createPlayer() { - val offlineFile = getOfflineFile() - val offlineIsComplete = offlineFile?.let { file.isOfflineAndIntact(offlineFile) } ?: false - initMediaController(offlineFile, offlineIsComplete) + mediaController.seekTo(mediaPosition) + } + } } - private fun getOfflineFile(): IOFile? { - return if (file.isOffline) { - file.getOfflineFile(requireContext(), previewSliderViewModel.userDrive.userId) - } else { - null + override fun onStop() { + super.onStop() + (parentFragment as BasePreviewSliderFragment).getMediaController { mediaController -> + if (!mediaController.isPlaying) { + mediaPosition = mediaController.currentPosition + } } } - private fun initMediaController(offlineFile: IOFile?, offlineIsComplete: Boolean) = with(binding) { - val context = requireContext() - val playbackSessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) - mediaControllerFuture = MediaController.Builder(context, playbackSessionToken).buildAsync().apply { - addListener( - getRunnable(offlineFile, offlineIsComplete, playerView), - mainExecutor, - ) - } + override fun onDestroyView() { + super.onDestroyView() + _binding = null } - private fun getRunnable(offlineFile: IOFile?, offlineIsComplete: Boolean, playerView: PlayerView): Runnable { - return Runnable { - mediaController = mediaControllerFuture?.get()?.apply { - setMediaItem(getMediaItem(offlineFile, offlineIsComplete)) - addListener(playerListener) - seekTo(mediaPosition) - } + private fun isOfflineFileComplete(offlineFile: IOFile?) = offlineFile?.let { file.isOfflineAndIntact(it) } ?: false - playerView.player = mediaController - playerView.controllerShowTimeoutMs = CONTROLLER_SHOW_TIMEOUT_MS - playerView.controllerHideOnTouch = false + fun onFragmentUnselected() { + (parentFragment as PreviewSliderFragment).getMediaController { mediaController -> + mediaController.pause() + mediaPosition = mediaController.currentPosition + binding.playerView.player = null } } - private fun getMediaItem(offlineFile: IOFile?, offlineIsComplete: Boolean): MediaItem { val mediaMetadata = MediaMetadata.Builder() .setTitle(file.name)