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" /> + + + +