diff --git a/Core b/Core
index c99081f5f9..cee57d95a2 160000
--- a/Core
+++ b/Core
@@ -1 +1 @@
-Subproject commit c99081f5f905d26f16135bb218c7fe5823f7a102
+Subproject commit cee57d95a2341321c7b96a09664853d72875f812
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c342252063..b102fdd835 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -214,9 +214,10 @@ dependencies {
ksp(core.hilt.androidx.compiler)
implementation(libs.exoplayer)
- implementation(libs.exoplayer.core)
- implementation(libs.exoplayer.ui)
- implementation(libs.extension.okhttp)
+ implementation(libs.exoplayer.dash)
+ implementation(libs.exoplayer.media3.ui)
+ implementation(libs.exoplayer.media3.datasource)
+ implementation(libs.exoplayer.media3.session)
implementation(libs.android.pdfview)
implementation(libs.gravity.snap.helper)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cba3c5e0f1..d4c335d71f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -39,6 +39,7 @@
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
android:minSdkVersion="34" />
+
@@ -103,11 +104,26 @@
android:name=".ui.MainActivity"
android:configChanges="orientation|screenSize|layoutDirection|screenLayout" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/infomaniak/drive/MainApplication.kt b/app/src/main/java/com/infomaniak/drive/MainApplication.kt
index 52d5b33293..d35a3573fc 100644
--- a/app/src/main/java/com/infomaniak/drive/MainApplication.kt
+++ b/app/src/main/java/com/infomaniak/drive/MainApplication.kt
@@ -92,6 +92,7 @@ open class MainApplication : Application(), SingletonImageLoader.Factory, Defaul
}
var geniusScanIsReady = false
+ var isVideoActivityInPIPMode = false
private val appUpdateWorkerScheduler by lazy { AppUpdateScheduler(applicationContext) }
diff --git a/app/src/main/java/com/infomaniak/drive/data/models/File.kt b/app/src/main/java/com/infomaniak/drive/data/models/File.kt
index c375edf8fb..72bbf5d4fc 100644
--- a/app/src/main/java/com/infomaniak/drive/data/models/File.kt
+++ b/app/src/main/java/com/infomaniak/drive/data/models/File.kt
@@ -194,6 +194,7 @@ open class File(
}
fun isPDF() = getFileType() == ExtensionType.PDF
+ fun isVideo() = getFileType() == ExtensionType.VIDEO
fun getFileType(): ExtensionType {
return if (isFromUploads) getFileTypeFromExtension() else when (extensionType) {
diff --git a/app/src/main/java/com/infomaniak/drive/ui/BasePreviewSliderFragment.kt b/app/src/main/java/com/infomaniak/drive/ui/BasePreviewSliderFragment.kt
index 0b49262839..e942352fec 100644
--- a/app/src/main/java/com/infomaniak/drive/ui/BasePreviewSliderFragment.kt
+++ b/app/src/main/java/com/infomaniak/drive/ui/BasePreviewSliderFragment.kt
@@ -22,11 +22,13 @@ import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.annotation.CallSuper
+import androidx.annotation.OptIn
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
+import androidx.media3.common.util.UnstableApi
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionManager
import androidx.viewpager2.widget.ViewPager2
@@ -47,6 +49,8 @@ import com.infomaniak.drive.ui.fileList.preview.PreviewPDFFragment
import com.infomaniak.drive.ui.fileList.preview.PreviewPDFHandler
import com.infomaniak.drive.ui.fileList.preview.PreviewSliderAdapter
import com.infomaniak.drive.ui.fileList.preview.PreviewSliderViewModel
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils
+import com.infomaniak.drive.ui.fileList.preview.playback.PreviewPlaybackFragment
import com.infomaniak.drive.utils.DrivePermissions
import com.infomaniak.drive.utils.Utils.openWith
import com.infomaniak.drive.utils.openOnlyOfficeDocument
@@ -60,6 +64,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
+@OptIn(UnstableApi::class)
abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnItemClickListener {
protected var _binding: FragmentPreviewSliderBinding? = null
@@ -77,8 +82,16 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte
private var isOverlayShown = true
override val currentContext by lazy { requireContext() }
+
+ private val bottomSheetUpdates = MutableSharedFlow(extraBufferCapacity = 1)
+
override lateinit var currentFile: File
+ var positionsForMedia: MutableMap = mutableMapOf()
+
+ // If the user want to navigate back and something is playing, we don't want to start PIP
+ private var hasNavigateBack = false
+
// This is not protected, otherwise it won't build because PublicSharePreviewSliderFragment needs it public for the interface
// it implements
val downloadPermissions: DrivePermissions = DrivePermissions(type = DrivePermissions.Type.DownloadingWithDownloadManager)
@@ -109,7 +122,7 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte
header.apply {
setupWindowInsetsListener(root, bottomSheetView) { pdfContainer.setMargins(right = it?.right ?: 0) }
setup(
- onBackClicked = findNavController()::popBackStack,
+ onBackClicked = ::navigateBack,
onOpenWithClicked = ::openWith,
onEditClicked = { openOnlyOfficeDocument(currentFile, mainViewModel.hasNetwork) },
)
@@ -127,36 +140,7 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte
isPublicShared = isPublicShared,
)
- viewPager.apply {
- adapter = previewSliderAdapter
- offscreenPageLimit = 1
-
- registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
- override fun onPageSelected(position: Int) {
- val file = previewSliderAdapter.getFile(position)
- currentFile = file
- previewSliderViewModel.currentPreview = file
-
- var shouldDisplayPageNumber = false
-
- childFragmentManager.findFragmentByTag("f${previewSliderAdapter.getItemId(position)}")?.apply {
- trackScreen()
- shouldDisplayPageNumber = this is PreviewPDFFragment && tryToUpdatePageCount()
- }
-
- with(header) {
- toggleEditVisibility(isVisible = file.isOnlyOfficePreview())
- setPageNumberVisibility(isVisible = shouldDisplayPageNumber)
- toggleOpenWithVisibility(isVisible = !isPublicShared && !file.isOnlyOfficePreview())
- }
-
- setPrintButtonVisibility(isGone = !file.isPDF() || !canDownloadFiles)
-
- (bottomSheetView as? FileInfoActionsView)?.openWith?.isGone = isPublicShared
- bottomSheetUpdates.tryEmit(file)
- }
- })
- }
+ initViewPager()
previewSliderViewModel.pdfIsDownloading.observe(viewLifecycleOwner) { isDownloading ->
if (!currentFile.isOnlyOfficePreview()) header.toggleOpenWithVisibility(isVisible = !isDownloading)
@@ -189,6 +173,14 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte
}
}
+ override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode)
+ if (isInPictureInPictureMode) {
+ binding.header.toggleVisibility(isVisible = false)
+ toggleBottomSheet(shouldShow = false)
+ }
+ }
+
override fun onPause() {
super.onPause()
if (noPreviewList()) return
@@ -200,6 +192,7 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte
super.onStop()
}
+ @OptIn(UnstableApi::class)
override fun onDestroyView() {
super.onDestroyView()
_binding?.previewSliderParent?.let(TransitionManager::endTransitions)
@@ -213,9 +206,61 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte
mainViewModel.currentPreviewFileList = LinkedHashMap()
}
+ // Release Player
+ PlaybackUtils.releasePlayer()
+
super.onDestroy()
}
+ private fun initViewPager() = with(binding) {
+ viewPager.apply {
+ adapter = previewSliderAdapter
+ offscreenPageLimit = 1
+
+ registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
+
+ @OptIn(UnstableApi::class)
+ override fun onPageSelected(position: Int) {
+
+ val file = previewSliderAdapter.getFile(position)
+ currentFile = file
+ previewSliderViewModel.currentPreview = file
+
+ var shouldDisplayPageNumber = false
+
+ val selectedFragment =
+ childFragmentManager.findFragmentByTag("f${previewSliderAdapter.getItemId(position)}")?.apply {
+ this.trackScreen()
+ shouldDisplayPageNumber = this is PreviewPDFFragment && tryToUpdatePageCount()
+ }
+
+ // Implementation of onFragmentUnselected to handle resume of media to the same position, only
+ // for PreviewPlaybackFragment.
+ childFragmentManager.fragments.filterIsInstance().forEach { fragment ->
+ if (fragment != selectedFragment) {
+ fragment.onFragmentUnselected()
+ }
+ }
+
+ with(header) {
+ toggleEditVisibility(isVisible = currentFile.isOnlyOfficePreview())
+ setPageNumberVisibility(isVisible = shouldDisplayPageNumber)
+ toggleOpenWithVisibility(isVisible = !isPublicShared && !currentFile.isOnlyOfficePreview())
+ }
+
+ setPrintButtonVisibility(isGone = !file.isPDF() || !canDownloadFiles)
+ (bottomSheetView as? FileInfoActionsView)?.openWith?.isGone = isPublicShared
+ bottomSheetUpdates.tryEmit(file)
+ }
+ })
+ }
+ }
+
+ private fun navigateBack() {
+ hasNavigateBack = true
+ findNavController().popBackStack()
+ }
+
protected fun noPreviewList() = mainViewModel.currentPreviewFileList.isEmpty()
protected open fun setBackActionHandlers() {
@@ -244,8 +289,6 @@ abstract class BasePreviewSliderFragment : Fragment(), FileInfoActionsView.OnIte
}
}
- private val bottomSheetUpdates = MutableSharedFlow(extraBufferCapacity = 1)
-
private fun clearEdgeToEdge() = with(requireActivity()) {
toggleSystemBar(true)
}
diff --git a/app/src/main/java/com/infomaniak/drive/ui/MainActivity.kt b/app/src/main/java/com/infomaniak/drive/ui/MainActivity.kt
index f86ef1fd38..ac4f49696e 100644
--- a/app/src/main/java/com/infomaniak/drive/ui/MainActivity.kt
+++ b/app/src/main/java/com/infomaniak/drive/ui/MainActivity.kt
@@ -21,6 +21,8 @@ import android.annotation.SuppressLint
import android.app.Dialog
import android.content.ContentResolver
import android.content.Context
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
@@ -40,6 +42,7 @@ import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
import androidx.activity.viewModels
+import androidx.annotation.OptIn
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.toBitmap
@@ -49,6 +52,7 @@ import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.findNavController
@@ -76,6 +80,7 @@ import com.infomaniak.core.legacy.utils.whenResultIsOk
import com.infomaniak.core.observe
import com.infomaniak.drive.GeniusScanUtils.scanResultProcessing
import com.infomaniak.drive.GeniusScanUtils.startScanFlow
+import com.infomaniak.drive.MainApplication
import com.infomaniak.drive.MatomoDrive.MatomoCategory
import com.infomaniak.drive.MatomoDrive.MatomoName
import com.infomaniak.drive.MatomoDrive.trackAccountEvent
@@ -101,6 +106,7 @@ import com.infomaniak.drive.extensions.trackDestination
import com.infomaniak.drive.ui.addFiles.AddFileBottomSheetDialogArgs
import com.infomaniak.drive.ui.bottomSheetDialogs.FileInfoActionsBottomSheetDialogArgs
import com.infomaniak.drive.ui.fileList.FileListFragmentArgs
+import com.infomaniak.drive.ui.fileList.preview.playback.VideoActivity
import com.infomaniak.drive.utils.AccountUtils
import com.infomaniak.drive.utils.DownloadOfflineFileManager
import com.infomaniak.drive.utils.DrivePermissions
@@ -427,6 +433,7 @@ class MainActivity : BaseActivity() {
}
//endregion
+ @OptIn(UnstableApi::class)
override fun onResume() {
super.onResume()
@@ -438,6 +445,19 @@ class MainActivity : BaseActivity() {
setBottomNavigationUserAvatar(this)
startContentObserverService()
+
+ finishPIPActivity()
+ }
+
+ @OptIn(UnstableApi::class)
+ private fun finishPIPActivity() {
+ lifecycleScope.launch {
+ if ((application as MainApplication).isVideoActivityInPIPMode) {
+ startActivity(Intent(this@MainActivity, VideoActivity::class.java).apply {
+ flags = FLAG_ACTIVITY_REORDER_TO_FRONT
+ })
+ }
+ }
}
private fun launchNextDeleteRequest() {
diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewMusicFragment.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewMusicFragment.kt
deleted file mode 100644
index 7c6fa5b0f7..0000000000
--- a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewMusicFragment.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Infomaniak kDrive - Android
- * Copyright (C) 2022-2025 Infomaniak Network SA
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package com.infomaniak.drive.ui.fileList.preview
-
-class PreviewMusicFragment : PreviewVideoFragment()
diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewSliderAdapter.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewSliderAdapter.kt
index 9bd9270ecc..56ee101b44 100644
--- a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewSliderAdapter.kt
+++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewSliderAdapter.kt
@@ -17,13 +17,16 @@
*/
package com.infomaniak.drive.ui.fileList.preview
+import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
+import androidx.media3.common.util.UnstableApi
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.infomaniak.drive.data.models.ExtensionType
import com.infomaniak.drive.data.models.File
import com.infomaniak.drive.data.models.UserDrive
+import com.infomaniak.drive.ui.fileList.preview.playback.PreviewPlaybackFragment
class PreviewSliderAdapter(
manager: FragmentManager,
@@ -37,14 +40,14 @@ class PreviewSliderAdapter(
override fun getItemCount() = files.size
+ @OptIn(UnstableApi::class)
override fun createFragment(position: Int): Fragment {
val file = getFile(position)
val args = PreviewFragmentArgs(fileId = file.id, userDrive = userDrive, isPublicShared = isPublicShared).toBundle()
return when (file.getFileType()) {
ExtensionType.IMAGE -> PreviewPictureFragment()
- ExtensionType.VIDEO -> PreviewVideoFragment()
- ExtensionType.AUDIO -> PreviewMusicFragment()
+ ExtensionType.VIDEO, ExtensionType.AUDIO -> PreviewPlaybackFragment()
ExtensionType.PDF -> PreviewPDFFragment()
else -> if (file.isOnlyOfficePreview()) PreviewPDFFragment() else PreviewOtherFragment()
}.apply { arguments = args }
diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewVideoFragment.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewVideoFragment.kt
deleted file mode 100644
index 8fa186802d..0000000000
--- a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/PreviewVideoFragment.kt
+++ /dev/null
@@ -1,233 +0,0 @@
-/*
- * Infomaniak kDrive - Android
- * Copyright (C) 2022-2025 Infomaniak Network SA
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package com.infomaniak.drive.ui.fileList.preview
-
-import android.content.Context
-import android.net.Uri
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.view.WindowManager
-import androidx.core.net.toUri
-import androidx.core.view.isGone
-import androidx.core.view.isVisible
-import com.google.android.exoplayer2.DefaultRenderersFactory
-import com.google.android.exoplayer2.ExoPlayer
-import com.google.android.exoplayer2.MediaItem
-import com.google.android.exoplayer2.PlaybackException
-import com.google.android.exoplayer2.Player
-import com.google.android.exoplayer2.RenderersFactory
-import com.google.android.exoplayer2.audio.AudioAttributes
-import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
-import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
-import com.google.android.exoplayer2.source.MediaSourceFactory
-import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
-import com.google.android.exoplayer2.upstream.DataSource
-import com.google.android.exoplayer2.upstream.DefaultDataSource
-import com.google.android.exoplayer2.upstream.FileDataSource
-import com.google.android.exoplayer2.util.EventLogger
-import com.google.android.exoplayer2.util.Util
-import com.infomaniak.core.auth.networking.HttpClient
-import com.infomaniak.core.legacy.networking.HttpUtils
-import com.infomaniak.core.legacy.networking.ManualAuthorizationRequired
-import com.infomaniak.drive.MatomoDrive.MatomoName
-import com.infomaniak.drive.MatomoDrive.trackMediaPlayerEvent
-import com.infomaniak.drive.R
-import com.infomaniak.drive.data.api.ApiRoutes
-import com.infomaniak.drive.databinding.FragmentPreviewVideoBinding
-import com.infomaniak.drive.ui.BasePreviewSliderFragment.Companion.openWithClicked
-import com.infomaniak.drive.ui.BasePreviewSliderFragment.Companion.toggleFullscreen
-import com.infomaniak.drive.utils.IOFile
-import com.infomaniak.core.network.networking.HttpClient.okHttpClient as unauthenticatedHttpClient
-
-open class PreviewVideoFragment : PreviewFragment() {
-
- private var _binding: FragmentPreviewVideoBinding? = null
- private val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView
-
- private var exoPlayer: ExoPlayer? = null
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- return FragmentPreviewVideoBinding.inflate(inflater, container, false).also { _binding = it }.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(binding) {
- super.onViewCreated(view, savedInstanceState)
-
- if (noCurrentFile()) return
-
- container.layoutTransition?.setAnimateParentHierarchy(false)
-
- errorLayout.apply {
- bigOpenWithButton.apply {
- isGone = true
- setOnClickListener { openWithClicked() }
- }
- fileIcon.setImageResource(file.getFileType().icon)
- fileName.text = file.name
- root.setOnClickListener { toggleFullscreen() }
- }
-
- playerView.setOnClickListener {
- if (playerView.isControllerFullyVisible) {
- trackMediaPlayerEvent(MatomoName.ToggleFullScreen)
- toggleFullscreen()
- }
- }
- }
-
- override fun onResume() {
- super.onResume()
- if (!noCurrentFile() && exoPlayer == null) initializePlayer()
- }
-
- override fun onPause() {
- exoPlayer?.pause()
- super.onPause()
- }
-
- override fun onDestroyView() {
- super.onDestroyView()
- _binding = null
- }
-
- override fun onDestroy() {
- exoPlayer?.apply {
- // Compute the percentage of the video the user watched before exiting
- trackMediaPlayerEvent(MatomoName.Duration, currentPosition.times(100).div(contentDuration + 1L).toFloat())
- release()
- }
- super.onDestroy()
- }
-
- private fun initializePlayer() {
- createPlayer()
- addPlayerListeners()
- }
-
- private fun createPlayer() = with(binding) {
- val context = requireContext()
-
- val offlineFile = if (file.isOffline) {
- val userId = previewSliderViewModel.userDrive.userId
- file.getOfflineFile(requireContext(), userId)
- } else {
- null
- }
- val offlineIsComplete = offlineFile?.let { file.isOfflineAndIntact(offlineFile) } ?: false
-
- val trackSelector = getTrackSelector(context)
-
- exoPlayer = ExoPlayer.Builder(context, getRenderersFactory(context.applicationContext))
- .setMediaSourceFactory(getMediaSourceFactory(context, offlineIsComplete))
- .setTrackSelector(trackSelector)
- .build()
-
- exoPlayer?.apply {
- addAnalyticsListener(EventLogger(trackSelector))
- setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */true)
- playWhenReady = false
-
- playerView.player = this
- playerView.controllerShowTimeoutMs = 1000
- playerView.controllerHideOnTouch = false
-
- setMediaItem(MediaItem.fromUri(getUri(offlineFile, offlineIsComplete)))
-
- prepare()
- }
- }
-
- private fun addPlayerListeners() {
- exoPlayer?.addListener(object : Player.Listener {
-
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- val flagKeepScreenOn = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
- if (isPlaying) {
- trackMediaPlayerEvent(MatomoName.Play)
- toggleFullscreen()
- activity?.window?.addFlags(flagKeepScreenOn)
- } else {
- trackMediaPlayerEvent(MatomoName.Pause)
- activity?.window?.clearFlags(flagKeepScreenOn)
- }
- }
-
- override fun onPlayerError(error: PlaybackException) {
- super.onPlayerError(error)
- error.printStackTrace()
-
- _binding?.errorLayout?.let {
- when (error.message) {
- "Source error" -> it.previewDescription.setText(R.string.previewVideoSourceError)
- else -> it.previewDescription.setText(R.string.previewLoadError)
- }
- it.bigOpenWithButton.isVisible = true
- it.root.isVisible = true
- it.previewDescription.isVisible = true
- }
- _binding?.playerView?.isGone = true
- }
- })
- }
-
- private fun getTrackSelector(context: Context): DefaultTrackSelector {
- return DefaultTrackSelector(context).apply {
- setParameters(buildUponParameters().setMaxVideoSizeSd())
- }
- }
-
- private fun getRenderersFactory(appContext: Context): RenderersFactory {
- return DefaultRenderersFactory(appContext)
- .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
- }
-
- private fun getMediaSourceFactory(context: Context, offlineIsComplete: Boolean): MediaSourceFactory {
- val dataSourceFactory = if (offlineIsComplete) getOfflineDataSourceFactory() else getDataSourceFactory(context)
- return DefaultMediaSourceFactory(dataSourceFactory)
- }
-
- private fun getOfflineDataSourceFactory(): DataSource.Factory {
- return DataSource.Factory { FileDataSource() }
- }
-
- private fun getDataSourceFactory(context: Context): DataSource.Factory {
- val appContext = context.applicationContext
- val userAgent = Util.getUserAgent(appContext, context.getString(R.string.app_name))
- val okHttpClient = when (navigationArgs?.isPublicShared) {
- true -> unauthenticatedHttpClient
- else -> HttpClient.okHttpClientWithTokenInterceptor
- }
- val okHttpDataSource = OkHttpDataSource.Factory(okHttpClient).apply {
- setUserAgent(userAgent)
-
- @OptIn(ManualAuthorizationRequired::class)
- setDefaultRequestProperties(HttpUtils.getHeaders().toMap())
- }
- return DefaultDataSource.Factory(appContext, okHttpDataSource)
- }
-
- private fun getUri(offlineFile: IOFile?, offlineIsComplete: Boolean): Uri {
- return if (offlineFile != null && offlineIsComplete) {
- offlineFile.toUri()
- } else {
- ApiRoutes.getDownloadFileUrl(file).toUri()
- }
- }
-}
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
new file mode 100644
index 0000000000..0ec7c3d455
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackService.kt
@@ -0,0 +1,63 @@
+/*
+ * Infomaniak kDrive - Android
+ * Copyright (C) 2024 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.drive.ui.fileList.preview.playback
+
+import android.content.Intent
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.setServiceOnDisconnect
+
+@UnstableApi
+class PlaybackService : MediaSessionService() {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ setServiceOnDisconnect {
+ release()
+ stopSelf()
+ }
+ }
+
+ // The user dismissed the app from the recent tasks
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ PlaybackUtils.mediaSession?.player?.let { player ->
+ if (!player.playWhenReady
+ || player.mediaItemCount == 0
+ || player.playbackState == Player.STATE_ENDED
+ || !player.isPlaying
+ ) {
+ release()
+ stopSelf()
+ }
+ }
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = PlaybackUtils.mediaSession
+
+ override fun onDestroy() {
+ release()
+ super.onDestroy()
+ }
+
+ private fun release() {
+ PlaybackUtils.releasePlayer()
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackUtils.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackUtils.kt
new file mode 100644
index 0000000000..95dd7ac910
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackUtils.kt
@@ -0,0 +1,224 @@
+/*
+ * Infomaniak kDrive - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.drive.ui.fileList.preview.playback
+
+import android.app.PendingIntent
+import android.app.PictureInPictureParams
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.util.Rational
+import androidx.core.net.toUri
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.common.util.Util
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSourceBitmapLoader
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import androidx.media3.exoplayer.DefaultRenderersFactory
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.RenderersFactory
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
+import androidx.media3.exoplayer.util.EventLogger
+import androidx.media3.session.MediaController
+import androidx.media3.session.MediaSession
+import androidx.media3.session.SessionToken
+import com.google.common.util.concurrent.ListenableFuture
+import com.infomaniak.core.auth.networking.HttpClient
+import com.infomaniak.core.legacy.utils.NotificationUtilsCore.Companion.PENDING_INTENT_FLAGS
+import com.infomaniak.core.network.networking.HttpUtils
+import com.infomaniak.core.network.networking.ManualAuthorizationRequired
+import com.infomaniak.drive.R
+import com.infomaniak.drive.data.api.ApiRoutes
+import com.infomaniak.drive.data.models.ExtensionType
+import com.infomaniak.drive.data.models.File
+import com.infomaniak.drive.ui.MainActivity
+import com.infomaniak.drive.utils.IOFile
+import java.util.concurrent.Executor
+
+@UnstableApi
+object PlaybackUtils {
+
+ const val CONTROLLER_SHOW_TIMEOUT_MS = 2000
+
+ var activePlayer: ExoPlayer? = null
+ var mediaSession: MediaSession? = null
+
+ private var onServiceDisconnect: (() -> Unit)? = null
+ private var mediaControllerFuture: ListenableFuture? = null
+ private var mediaController: MediaController? = null
+
+ fun setServiceOnDisconnect(onDisconnect: () -> Unit) {
+ this.onServiceDisconnect = onDisconnect
+ }
+
+ fun Context.getMediaController(mainExecutor: Executor, callback: (MediaController) -> Unit) {
+ if (mediaController == null) {
+ val playbackSessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java))
+ mediaControllerFuture = MediaController.Builder(this, playbackSessionToken).buildAsync().apply {
+ addListener(
+ getRunnable(callback),
+ mainExecutor,
+ )
+ }
+ } else {
+ callback(mediaController!!)
+ }
+ }
+
+ fun releasePlayer() {
+ activePlayer?.release()
+ activePlayer = null
+
+ mediaControllerFuture?.let { MediaController.releaseFuture(it) }
+ mediaControllerFuture = null
+
+ mediaController?.release()
+ mediaController = null
+
+ mediaSession?.release()
+ mediaSession = null
+
+ onServiceDisconnect = null
+ }
+
+ fun Context.setMediaSession() {
+ if (mediaSession == null) {
+ mediaSession = MediaSession.Builder(this, activePlayer!!)
+ .setCallback(getMediaSessionCallback())
+ .setBitmapLoader(getBitmapLoader())
+ .setSessionActivity(getPendingIntent())
+ .build()
+ } else {
+ mediaSession?.player = activePlayer!!
+ }
+ }
+
+ fun Context.getExoPlayer(): ExoPlayer {
+ return ExoPlayer.Builder(this, getRenderersFactory())
+ .setMediaSourceFactory(DefaultMediaSourceFactory(getDataSourceFactory()))
+ .setTrackSelector(getTrackSelector())
+ .build().apply {
+ addAnalyticsListener(EventLogger())
+ setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */true)
+ playWhenReady = false
+ prepare()
+ }
+ }
+
+ fun getMediaItem(file: File, offlineFile: IOFile?, offlineIsComplete: Boolean): MediaItem {
+ val uri = getUri(file, offlineFile, offlineIsComplete)
+ val mediaMetadataBuilder = MediaMetadata.Builder()
+ .setTitle(file.name)
+
+ if (file.getFileType() == ExtensionType.VIDEO) {
+ mediaMetadataBuilder.setArtworkUri(getThumbnailUri(file))
+ }
+
+ return MediaItem.Builder()
+ .setMediaId(file.id.toString())
+ .setMediaMetadata(mediaMetadataBuilder.build())
+ .setUri(uri)
+ .build()
+ }
+
+ fun getPictureInPictureParams(ratio: Rational): PictureInPictureParams? {
+ val pipParams = PictureInPictureParams.Builder().setAspectRatio(ratio)
+
+ if (Build.VERSION.SDK_INT >= 31) pipParams.setAutoEnterEnabled(true)
+
+ return pipParams.build()
+ }
+
+ private fun getRunnable(callback: (MediaController) -> Unit): Runnable {
+ return Runnable {
+ if (mediaController == null) {
+ mediaController = mediaControllerFuture?.get()?.apply {
+ callback(this)
+ }
+ } else {
+ callback(mediaController!!)
+ }
+ }
+ }
+
+ private fun getThumbnailUri(file: File): Uri {
+ return ApiRoutes.getThumbnailUrl(file).toUri()
+ }
+
+ private fun getUri(file: File, offlineFile: IOFile?, offlineIsComplete: Boolean): Uri {
+ return if (offlineFile != null && offlineIsComplete) {
+ offlineFile.toUri()
+ } else {
+ ApiRoutes.getDownloadFileUrl(file).toUri()
+ }
+ }
+
+ @OptIn(ManualAuthorizationRequired::class)
+ private fun Context.getDataSourceFactory(): DataSource.Factory {
+ val okHttpDataSource = OkHttpDataSource.Factory(HttpClient.okHttpClientWithTokenInterceptor).apply {
+ setUserAgent(Util.getUserAgent(this@getDataSourceFactory, getString(R.string.app_name)))
+ setDefaultRequestProperties(HttpUtils.getHeaders().toMap())
+ }
+ return DefaultDataSource.Factory(this, okHttpDataSource)
+ }
+
+ private fun getMediaSessionCallback(): MediaSession.Callback {
+ return object : MediaSession.Callback {
+
+ // 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 PreviewPlaybackFragment.
+ override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) {
+ super.onDisconnected(session, controller)
+ onServiceDisconnect?.invoke()
+ }
+ }
+ }
+
+ private fun Context.getRenderersFactory(): RenderersFactory {
+ return DefaultRenderersFactory(this)
+ .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
+ }
+
+ private fun Context.getTrackSelector(): DefaultTrackSelector {
+ return DefaultTrackSelector(this).apply {
+ setParameters(buildUponParameters().setMaxVideoSizeSd())
+ }
+ }
+
+ private fun Context.getPendingIntent() = PendingIntent.getActivity(this, 0, getIntentForMedia(), PENDING_INTENT_FLAGS)
+
+ private fun Context.getIntentForMedia(): Intent {
+ return Intent(this, MainActivity::class.java).apply {
+ action = Intent.ACTION_MAIN
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ }
+
+ private fun Context.getBitmapLoader(): DataSourceBitmapLoader {
+ return DataSourceBitmapLoader(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(), getDataSourceFactory())
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackViewModel.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackViewModel.kt
new file mode 100644
index 0000000000..a21c8567a2
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlaybackViewModel.kt
@@ -0,0 +1,52 @@
+/*
+ * Infomaniak kDrive - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.drive.ui.fileList.preview.playback
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import com.infomaniak.drive.data.cache.FileController
+import com.infomaniak.drive.data.models.File
+import com.infomaniak.drive.data.models.UserDrive
+import com.infomaniak.drive.utils.IOFile
+
+class PlaybackViewModel(application: Application) : AndroidViewModel(application) {
+
+ val offlineFile: IOFile? by lazy {
+ if (currentFile?.isOffline == true) {
+ currentFile?.getOfflineFile(getApplication(), userDrive.userId)
+ } else {
+ null
+ }
+ }
+
+ val offlineIsComplete by lazy { isOfflineFileComplete(offlineFile) }
+
+ var currentFile: File? = null
+
+ private val userDrive by lazy { UserDrive() }
+
+ fun loadFile(fileId: Int) {
+ currentFile = runCatching {
+ FileController.getFileById(fileId, userDrive)
+ }.getOrElse { _ ->
+ null
+ }
+ }
+
+ private fun isOfflineFileComplete(offlineFile: IOFile?) = offlineFile?.let { currentFile?.isOfflineAndIntact(it) } ?: false
+}
diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlayerListener.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlayerListener.kt
new file mode 100644
index 0000000000..26bd75895a
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PlayerListener.kt
@@ -0,0 +1,56 @@
+/*
+ * Infomaniak kDrive - Android
+ * Copyright (C) 2024-2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.drive.ui.fileList.preview.playback
+
+import android.app.Activity
+import android.content.Context
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import com.infomaniak.core.sentry.SentryLog
+import com.infomaniak.drive.MatomoDrive.trackEvent
+
+class PlayerListener(
+ private val activity: Activity?,
+ private val isPlayingChanged: (Boolean) -> Unit,
+ private val onError: (String?) -> Unit,
+) : Player.Listener {
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ activity?.trackMediaPlayerEvent(if (isPlaying) "play" else "pause")
+ isPlayingChanged(isPlaying)
+ }
+
+ override fun onPlayerError(playbackException: PlaybackException) {
+ super.onPlayerError(playbackException)
+ handlePlayerError(playbackException)
+ }
+
+ private fun handlePlayerError(playbackException: PlaybackException) {
+ SentryLog.d(TAG, "Error during media playback", playbackException)
+ onError(playbackException.message)
+ }
+
+ companion object {
+
+ private const val TAG = "PlayerListener"
+
+ fun Context.trackMediaPlayerEvent(name: String, value: Float? = null) {
+ trackEvent("mediaPlayer", name, value = value)
+ }
+ }
+}
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
new file mode 100644
index 0000000000..dc5ce716f2
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/PreviewPlaybackFragment.kt
@@ -0,0 +1,200 @@
+/*
+ * Infomaniak kDrive - Android
+ * Copyright (C) 2022-2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.drive.ui.fileList.preview.playback
+
+import android.content.Intent
+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.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import com.infomaniak.core.extensions.isDontKeepActivitiesEnabled
+import com.infomaniak.drive.R
+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.playback.PlaybackUtils.CONTROLLER_SHOW_TIMEOUT_MS
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.getExoPlayer
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.getMediaController
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.getMediaItem
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.setMediaSession
+import com.infomaniak.drive.ui.fileList.preview.playback.PlayerListener.Companion.trackMediaPlayerEvent
+import com.infomaniak.drive.utils.IOFile
+import com.infomaniak.drive.utils.shouldExcludeFromRecents
+
+@UnstableApi
+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 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) }
+
+ private val playerListener = PlayerListener(
+ activity,
+ isPlayingChanged = { isPlaying ->
+ if (isPlaying) {
+ toggleFullscreen()
+ activity?.window?.addFlags(flagKeepScreenOn)
+ } else {
+ activity?.window?.clearFlags(flagKeepScreenOn)
+ }
+ },
+ onError = { playbackExceptionMessage ->
+ _binding?.errorLayout?.apply {
+ when (playbackExceptionMessage) {
+ "Source error" -> previewDescription.setText(R.string.previewVideoSourceError)
+ else -> previewDescription.setText(R.string.previewLoadError)
+ }
+ bigOpenWithButton.isVisible = true
+ root.isVisible = true
+ previewDescription.isVisible = true
+ }
+ _binding?.playerView?.isGone = true
+ },
+ )
+
+ private val exoPlayer: ExoPlayer by lazy { requireContext().getExoPlayer() }
+ private val mainExecutor by lazy { ContextCompat.getMainExecutor(requireContext()) }
+
+ private val exoPlayerUIToHide = listOf(
+ R.id.exo_rew_with_amount,
+ R.id.exo_ffwd_with_amount,
+ R.id.exo_progress,
+ R.id.exo_bottom_bar,
+ )
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ return FragmentPreviewPlaybackBinding.inflate(inflater, container, false).also { _binding = it }.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(binding) {
+ super.onViewCreated(view, savedInstanceState)
+
+ if (noCurrentFile()) return
+
+ container.layoutTransition?.setAnimateParentHierarchy(false)
+
+ errorLayout.apply {
+ bigOpenWithButton.apply {
+ isGone = true
+ setOnClickListener { openWithClicked() }
+ }
+ fileIcon.setImageResource(file.getFileType().icon)
+ fileName.text = file.name
+ root.setOnClickListener { toggleFullscreen() }
+ }
+
+ playerView.setOnClickListener {
+ if ((it as PlayerView).isControllerFullyVisible) {
+ context?.trackMediaPlayerEvent("toggleFullScreen")
+ toggleFullscreen()
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ //To avoid having the notification when we play a video, we have to avoid using the MediaController
+ if (file.isVideo().not()) {
+ PlaybackUtils.activePlayer = exoPlayer
+ requireContext().setMediaSession()
+ requireActivity().shouldExcludeFromRecents(false)
+ requireContext().getMediaController(mainExecutor) {
+ if (exoPlayer.currentMediaItem == null) setMediaToExoPlayer()
+ }
+ } else {
+ setMediaToExoPlayer()
+ initVideoPlayerUI()
+ }
+ }
+
+ private fun initVideoPlayerUI() {
+ with(binding.playerView) {
+
+ // Hiding ExoPlayer interface elements because we'll play the video in a separate Activity
+ exoPlayerUIToHide.forEach { uiID -> findViewById(uiID).isVisible = false }
+
+ // We'll open a new activity for videos to handle PIP perfectly
+ findViewById(R.id.exo_play_pause).setOnClickListener {
+ with(requireActivity()) {
+ shouldExcludeFromRecents(!isDontKeepActivitiesEnabled())
+ }
+ startActivity(Intent(requireActivity(), VideoActivity::class.java).apply {
+ putExtras(VideoActivityArgs(fileId = file.id).toBundle())
+ })
+ }
+ }
+ }
+
+ private fun setMediaToExoPlayer() {
+ exoPlayer.removeListener(playerListener)
+ exoPlayer.addListener(playerListener)
+
+ exoPlayer.setMediaItem(
+ getMediaItem(file, offlineFile, offlineIsComplete),
+ (parentFragment as BasePreviewSliderFragment).positionsForMedia[file.id] ?: 0L,
+ )
+
+ binding.playerView.player = exoPlayer
+ binding.playerView.controllerShowTimeoutMs = CONTROLLER_SHOW_TIMEOUT_MS
+ binding.playerView.controllerHideOnTouch = false
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Compute the percentage of the media the user watched before exiting
+ val currentMediaPercentage = exoPlayer.currentPosition.times(100)
+ val currentMediaDuration = exoPlayer.contentDuration
+ requireContext().trackMediaPlayerEvent("duration", currentMediaPercentage.div(currentMediaDuration + 1).toFloat())
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ binding.playerView.player?.release()
+ _binding = null
+ }
+
+ fun onFragmentUnselected() {
+ if (exoPlayer.currentMediaItem?.mediaId?.toInt() == file.id) {
+ exoPlayer.pause()
+ (parentFragment as BasePreviewSliderFragment).positionsForMedia[file.id] = exoPlayer.currentPosition
+ }
+ }
+
+ private fun isOfflineFileComplete(offlineFile: IOFile?) = offlineFile?.let { file.isOfflineAndIntact(it) } ?: false
+}
diff --git a/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/VideoActivity.kt b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/VideoActivity.kt
new file mode 100644
index 0000000000..7623346816
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/drive/ui/fileList/preview/playback/VideoActivity.kt
@@ -0,0 +1,136 @@
+/*
+ * Infomaniak kDrive - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.drive.ui.fileList.preview.playback
+
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Bundle
+import android.util.Rational
+import android.view.WindowManager
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import com.infomaniak.core.extensions.isDontKeepActivitiesEnabled
+import com.infomaniak.drive.MainApplication
+import com.infomaniak.drive.R
+import com.infomaniak.drive.databinding.ActivityVideoBinding
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.CONTROLLER_SHOW_TIMEOUT_MS
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.getExoPlayer
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.getMediaItem
+import com.infomaniak.drive.ui.fileList.preview.playback.PlaybackUtils.getPictureInPictureParams
+import com.infomaniak.drive.utils.shouldExcludeFromRecents
+import com.infomaniak.drive.utils.toggleSystemBar
+
+@UnstableApi
+class VideoActivity : AppCompatActivity() {
+
+ private val viewModel: PlaybackViewModel by viewModels()
+
+ private val binding by lazy { ActivityVideoBinding.inflate(layoutInflater) }
+
+ private val flagKeepScreenOn by lazy { WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON }
+
+ private val playerListener = PlayerListener(
+ this,
+ isPlayingChanged = { isPlaying ->
+ if (isPlaying) {
+ setPIPParams()
+ window?.addFlags(flagKeepScreenOn)
+ } else {
+ window?.clearFlags(flagKeepScreenOn)
+ }
+ },
+ onError = { playbackExceptionMessage ->
+ binding.errorLayout.apply {
+ when (playbackExceptionMessage) {
+ "Source error" -> previewDescription.setText(R.string.previewVideoSourceError)
+ else -> previewDescription.setText(R.string.previewLoadError)
+ }
+ bigOpenWithButton.isVisible = true
+ root.isVisible = true
+ previewDescription.isVisible = true
+ }
+ binding.playerView.isGone = true
+ },
+ )
+
+ private val exoPlayer: ExoPlayer by lazy { getExoPlayer() }
+ private val videoRatio by lazy {
+ exoPlayer.videoFormat?.let { videoFormat ->
+ if (videoFormat.width > videoFormat.height) {
+ Rational(16, 9)
+ } else {
+ Rational(9, 16)
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(binding.root)
+
+ shouldExcludeFromRecents(!isDontKeepActivitiesEnabled())
+
+ with(binding.playerView) {
+ player = exoPlayer
+ controllerShowTimeoutMs = CONTROLLER_SHOW_TIMEOUT_MS
+ controllerHideOnTouch = false
+ showController()
+ }
+
+ exoPlayer.addListener(playerListener)
+ exoPlayer.playWhenReady = true
+
+ loadVideo(intent)
+
+ toggleSystemBar(show = false)
+ }
+
+ override fun onDestroy() {
+ binding.playerView.player?.release()
+ super.onDestroy()
+ }
+
+ override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
+ (application as MainApplication).isVideoActivityInPIPMode = isInPictureInPictureMode
+ }
+
+ private fun setPIPParams() {
+ videoRatio?.let {
+ getPictureInPictureParams(it)?.let { pictureInPictureParams ->
+ setPictureInPictureParams(pictureInPictureParams)
+ }
+ }
+ }
+
+ private fun loadVideo(intent: Intent) {
+ intent.extras?.let { VideoActivityArgs.fromBundle(it) }?.fileId?.let { videoFileId ->
+ if (videoFileId > 0) {
+ viewModel.loadFile(videoFileId)
+ exoPlayer.setMediaItem(getMediaItem(viewModel.currentFile!!, viewModel.offlineFile, viewModel.offlineIsComplete))
+ } else {
+ finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/drive/utils/Extensions.kt b/app/src/main/java/com/infomaniak/drive/utils/Extensions.kt
index a5ea5478ec..3a1019f1ac 100644
--- a/app/src/main/java/com/infomaniak/drive/utils/Extensions.kt
+++ b/app/src/main/java/com/infomaniak/drive/utils/Extensions.kt
@@ -33,6 +33,7 @@ import android.os.Build.VERSION.SDK_INT
import android.os.Environment
import android.os.StatFs
import android.provider.MediaStore
+import android.provider.Settings
import android.transition.AutoTransition
import android.transition.TransitionManager
import android.transition.TransitionSet
@@ -175,6 +176,13 @@ fun Activity.toggleSystemBar(show: Boolean) {
}
}
+fun Activity.shouldExcludeFromRecents(exclude: Boolean) {
+ val tasks = (getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).appTasks
+ if (tasks != null && tasks.isNotEmpty()) {
+ tasks[0].setExcludeFromRecents(exclude)
+ }
+}
+
fun String.isValidUrl(): Boolean = Patterns.WEB_URL.matcher(this).matches()
fun ItemUserBinding.setUserView(
diff --git a/app/src/main/res/layout/activity_video.xml b/app/src/main/res/layout/activity_video.xml
new file mode 100644
index 0000000000..322466717f
--- /dev/null
+++ b/app/src/main/res/layout/activity_video.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_preview_video.xml b/app/src/main/res/layout/fragment_preview_playback.xml
similarity index 91%
rename from app/src/main/res/layout/fragment_preview_video.xml
rename to app/src/main/res/layout/fragment_preview_playback.xml
index 7c3268ee20..58b40c5497 100644
--- a/app/src/main/res/layout/fragment_preview_video.xml
+++ b/app/src/main/res/layout/fragment_preview_playback.xml
@@ -22,15 +22,16 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/previewBackground"
- tools:context=".ui.fileList.preview.PreviewVideoFragment">
+ tools:context=".ui.fileList.preview.playback.PreviewPlaybackFragment">
-
+
diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml
index 39ceb98885..e850b50750 100644
--- a/app/src/main/res/navigation/main_navigation.xml
+++ b/app/src/main/res/navigation/main_navigation.xml
@@ -368,6 +368,16 @@
app:nullable="true" />
+
+
+
+