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)