From 640206f2a53a3ddb2ec35c9c3a75de094453250f Mon Sep 17 00:00:00 2001 From: Denis Botvin Date: Mon, 17 Nov 2025 15:55:37 +0200 Subject: [PATCH 01/69] Enhanced player implementation Signed-off-by: Denis Botvin # Conflicts: # app/src/main/res/values/colors.xml # app/src/main/res/values/strings.xml # gradle/libs.versions.toml Signed-off-by: alperozturk96 # Conflicts: # app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt # Conflicts: # app/src/main/res/values/styles.xml # Conflicts: # app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt # Conflicts: # app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt # app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt # Conflicts: # app/src/main/res/values/dims.xml --- app/build.gradle.kts | 4 + .../player/media3/Media3PlaybackModelTest.kt | 271 +++++++++++++++ .../player/media3/PlaybackStateFactoryTest.kt | 97 ++++++ .../media3/common/MediaItemFactoryTest.kt | 41 +++ .../client/player/media3/common/TestPlayer.kt | 163 +++++++++ .../player/media3/common/TestPlayerFactory.kt | 16 + .../player/media3/common/TestPlayerTest.kt | 141 ++++++++ .../controller/TestMediaControllerFactory.kt | 26 ++ .../datasource/DefaultDataSourceTest.kt | 132 ++++++++ .../PlaybackResumptionConfigStoreTest.kt | 56 ++++ .../PlaybackResumptionLauncherTest.kt | 141 ++++++++ .../PlaybackResumptionPlayerListenerTest.kt | 39 +++ .../session/MediaSessionBitmapLoaderTest.kt | 119 +++++++ .../media3/session/TestMediaSessionFactory.kt | 24 ++ .../player/model/PlaybackSettingsTest.kt | 65 ++++ .../model/file/PlaybackFileMapperTest.kt | 57 ++++ .../model/file/PlaybackFileUriMapperTest.kt | 47 +++ .../model/file/PlaybackFilesRepositoryTest.kt | 311 ++++++++++++++++++ .../ui/control/PlayerControlViewTest.kt | 174 ++++++++++ app/src/main/AndroidManifest.xml | 14 +- .../nextcloud/client/database/dao/FileDao.kt | 7 + .../com/nextcloud/client/di/AppComponent.java | 2 + .../nextcloud/client/player/PlayerModule.kt | 123 +++++++ .../client/player/media3/ExoPlayerFactory.kt | 34 ++ .../player/media3/Media3PlaybackModel.kt | 224 +++++++++++++ .../media3/MediaNotificationProvider.kt | 25 ++ .../media3/PlaybackModelPlayerListener.kt | 88 +++++ .../client/player/media3/PlaybackService.kt | 72 ++++ .../player/media3/PlaybackStateFactory.kt | 84 +++++ .../player/media3/common/MediaItemFactory.kt | 29 ++ .../player/media3/common/MediaMetadata.kt | 23 ++ .../player/media3/common/PlayerFactory.kt | 14 + .../DefaultMediaControllerFactory.kt | 29 ++ .../media3/controller/MediaController.kt | 60 ++++ .../controller/MediaControllerFactory.kt | 14 + .../media3/datasource/DefaultDataSource.kt | 66 ++++ .../datasource/DefaultDataSourceFactory.kt | 51 +++ .../resumption/PlaybackResumptionConfig.kt | 18 + .../PlaybackResumptionConfigStore.kt | 67 ++++ .../resumption/PlaybackResumptionLauncher.kt | 72 ++++ .../PlaybackResumptionPlayerListener.kt | 21 ++ .../session/DefaultMediaSessionFactory.kt | 49 +++ .../session/MediaSessionActivityFactory.kt | 37 +++ .../session/MediaSessionBitmapLoader.kt | 117 +++++++ .../media3/session/MediaSessionCallback.kt | 62 ++++ .../media3/session/MediaSessionFactory.kt | 14 + .../media3/session/MediaSessionHolder.kt | 17 + .../player/model/GlideThumbnailLoader.kt | 74 +++++ .../client/player/model/PlaybackModel.kt | 61 ++++ .../model/PlaybackModelCompositeListener.kt | 36 ++ .../client/player/model/PlaybackSettings.kt | 50 +++ .../client/player/model/ThumbnailLoader.kt | 25 ++ .../error/DefaultPlaybackErrorStrategy.kt | 22 ++ .../model/error/PlaybackErrorStrategy.kt | 15 + .../player/model/error/SourceException.kt | 13 + .../client/player/model/file/PlaybackFile.kt | 22 ++ .../player/model/file/PlaybackFileMapper.kt | 38 +++ .../player/model/file/PlaybackFileType.kt | 13 + .../model/file/PlaybackFileUriMapper.kt | 28 ++ .../client/player/model/file/PlaybackFiles.kt | 10 + .../model/file/PlaybackFilesComparator.kt | 49 +++ .../model/file/PlaybackFilesRepository.kt | 160 +++++++++ .../model/state/PlaybackItemMetadata.kt | 21 ++ .../player/model/state/PlaybackItemState.kt | 20 ++ .../player/model/state/PlaybackState.kt | 18 + .../client/player/model/state/PlayerState.kt | 18 + .../client/player/model/state/RepeatMode.kt | 16 + .../client/player/model/state/VideoSize.kt | 12 + .../client/player/ui/PlayerActivity.kt | 241 ++++++++++++++ .../client/player/ui/PlayerLauncher.kt | 61 ++++ .../player/ui/PlayerProgressIndicator.kt | 88 +++++ .../client/player/ui/PlayerScreenEvent.kt | 27 ++ .../nextcloud/client/player/ui/PlayerView.kt | 117 +++++++ .../client/player/ui/PlayerViewModel.kt | 114 +++++++ .../player/ui/audio/AudioFileFragment.kt | 124 +++++++ .../ui/audio/AudioFileFragmentFactory.kt | 17 + .../client/player/ui/audio/AudioPlayerView.kt | 46 +++ .../ui/control/MultipleClickListener.kt | 51 +++ .../player/ui/control/PlayerControlView.kt | 230 +++++++++++++ .../client/player/ui/pager/PlayerPager.kt | 224 +++++++++++++ .../ui/pager/PlayerPagerFragmentFactory.kt | 14 + .../player/ui/pager/PlayerPagerListener.kt | 12 + .../client/player/ui/pager/PlayerPagerMode.kt | 13 + .../adapter/AbstractFragmentPagerAdapter.kt | 81 +++++ .../adapter/DefaultFragmentPagerAdapter.kt | 31 ++ .../adapter/InfiniteFragmentPagerAdapter.kt | 68 ++++ .../player/ui/video/VideoFileFragment.kt | 131 ++++++++ .../ui/video/VideoFileFragmentFactory.kt | 17 + .../client/player/ui/video/VideoPlayerView.kt | 102 ++++++ .../client/player/util/ContentResolver.kt | 26 ++ .../nextcloud/client/player/util/Context.kt | 30 ++ .../nextcloud/client/player/util/ImageView.kt | 19 ++ .../com/nextcloud/client/player/util/List.kt | 25 ++ .../client/player/util/PeriodicAction.kt | 29 ++ .../client/player/util/ScreenUtils.kt | 43 +++ .../client/player/util/WindowWrapper.kt | 53 +++ .../datamodel/FileDataStorageManager.java | 40 +++ .../providers/FileContentProvider.java | 43 +++ .../android/ui/adapter/OCFileListAdapter.java | 4 + .../adapter/OCFileListGridItemViewHolder.kt | 3 + .../ui/fragment/OCFileListFragment.java | 4 + .../player_ic_notification_audio.xml | 21 ++ .../player_ic_notification_video.xml | 21 ++ app/src/main/res/drawable/player_ic_audio.xml | 16 + app/src/main/res/drawable/player_ic_close.xml | 16 + .../drawable/player_ic_notification_audio.xml | 15 + .../drawable/player_ic_notification_video.xml | 15 + app/src/main/res/drawable/player_ic_pause.xml | 23 ++ app/src/main/res/drawable/player_ic_play.xml | 23 ++ .../main/res/drawable/player_ic_repeat.xml | 16 + .../main/res/drawable/player_ic_shuffle.xml | 16 + .../main/res/drawable/player_ic_skip_next.xml | 16 + .../res/drawable/player_ic_skip_previous.xml | 16 + app/src/main/res/drawable/player_ic_video.xml | 16 + .../res/drawable/player_progress_drawable.xml | 25 ++ .../res/drawable/player_progress_thumb.xml | 16 + app/src/main/res/layout/grid_item.xml | 10 + app/src/main/res/layout/list_item.xml | 8 + .../res/layout/player_audio_file_fragment.xml | 74 +++++ app/src/main/res/layout/player_audio_view.xml | 71 ++++ .../main/res/layout/player_control_view.xml | 80 +++++ app/src/main/res/layout/player_pager.xml | 11 + .../res/layout/player_video_file_fragment.xml | 32 ++ app/src/main/res/layout/player_video_view.xml | 70 ++++ app/src/main/res/values-land/dims.xml | 16 + app/src/main/res/values-large-land/dims.xml | 16 + app/src/main/res/values-large/dims.xml | 18 + .../MediaControllerExtensionsTest.kt | 107 ++++++ .../error/DefaultPlaybackErrorStrategyTest.kt | 67 ++++ .../model/file/PlaybackFilesComparatorTest.kt | 145 ++++++++ .../client/player/util/ListExtensionsTest.kt | 28 ++ 131 files changed, 7229 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt create mode 100644 app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/PlayerModule.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/Context.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/ImageView.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/List.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt create mode 100644 app/src/main/res/drawable-v33/player_ic_notification_audio.xml create mode 100644 app/src/main/res/drawable-v33/player_ic_notification_video.xml create mode 100644 app/src/main/res/drawable/player_ic_audio.xml create mode 100644 app/src/main/res/drawable/player_ic_close.xml create mode 100644 app/src/main/res/drawable/player_ic_notification_audio.xml create mode 100644 app/src/main/res/drawable/player_ic_notification_video.xml create mode 100644 app/src/main/res/drawable/player_ic_pause.xml create mode 100644 app/src/main/res/drawable/player_ic_play.xml create mode 100644 app/src/main/res/drawable/player_ic_repeat.xml create mode 100644 app/src/main/res/drawable/player_ic_shuffle.xml create mode 100644 app/src/main/res/drawable/player_ic_skip_next.xml create mode 100644 app/src/main/res/drawable/player_ic_skip_previous.xml create mode 100644 app/src/main/res/drawable/player_ic_video.xml create mode 100644 app/src/main/res/drawable/player_progress_drawable.xml create mode 100644 app/src/main/res/drawable/player_progress_thumb.xml create mode 100644 app/src/main/res/layout/player_audio_file_fragment.xml create mode 100644 app/src/main/res/layout/player_audio_view.xml create mode 100644 app/src/main/res/layout/player_control_view.xml create mode 100644 app/src/main/res/layout/player_pager.xml create mode 100644 app/src/main/res/layout/player_video_file_fragment.xml create mode 100644 app/src/main/res/layout/player_video_view.xml create mode 100644 app/src/main/res/values-land/dims.xml create mode 100644 app/src/main/res/values-large-land/dims.xml create mode 100644 app/src/main/res/values-large/dims.xml create mode 100644 app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt create mode 100644 app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt create mode 100644 app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt create mode 100644 app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c28ced6a35b6..061ead8b3435 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -526,6 +526,10 @@ dependencies { // region Kotlin implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.guava) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.kotlinx.coroutines.test) // endregion // region Stateless diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt new file mode 100644 index 000000000000..49b97b0c18a4 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt @@ -0,0 +1,271 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import android.content.Context +import androidx.test.annotation.UiThreadTest +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.client.player.media3.common.MediaItemFactory +import com.nextcloud.client.player.media3.common.TestPlayerFactory +import com.nextcloud.client.player.media3.controller.TestMediaControllerFactory +import com.nextcloud.client.player.media3.session.MediaSessionHolder +import com.nextcloud.client.player.media3.session.TestMediaSessionFactory +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.PlaybackSettings +import com.nextcloud.client.player.model.error.DefaultPlaybackErrorStrategy +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.PlaybackFiles +import com.nextcloud.client.player.model.file.PlaybackFilesComparator +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class Media3PlaybackModelTest { + private lateinit var settings: PlaybackSettings + private lateinit var model: PlaybackModel + + private val testDispatcher = StandardTestDispatcher() + + @Before + @UiThreadTest + fun setup() { + val context: Context = ApplicationProvider.getApplicationContext() + val playerFactory = TestPlayerFactory() + val sessionFactory = TestMediaSessionFactory(context, playerFactory) + + settings = PlaybackSettings(context) + + model = Media3PlaybackModel( + PlaybackStateFactory(), + sessionFactory, + TestMediaControllerFactory(context) { model as MediaSessionHolder }, + settings, + MediaItemFactory(), + DefaultPlaybackErrorStrategy() + ) + + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + @UiThreadTest + fun start_initialState() = runModelTest { model -> + model.state.get().let { state -> + assertTrue(state.currentFiles.isEmpty()) + assertNull(state.currentItemState) + assertEquals(settings.repeatMode, state.repeatMode) + assertEquals(settings.isShuffle, state.shuffle) + } + } + + @Test + @UiThreadTest + fun setFiles_initialQueue() = runModelTest { model -> + val inputFiles = playbackFiles(3) + + model.setFiles(inputFiles) + + model.state.get().let { state -> + assertEquals(3, state.currentFiles.size) + assertNotEquals(PlayerState.PLAYING, state.currentItemState!!.playerState) + } + + model.play() + + model.state.get().let { state -> + assertEquals(PlayerState.PLAYING, state.currentItemState!!.playerState) + assertEquals(inputFiles.list.first().id, state.currentItemState.file.id) + } + } + + @Test + @UiThreadTest + fun setFiles_updateRetainCurrentItem() = runModelTest { model -> + val inputFiles = playbackFiles(3) + val initialFileId = inputFiles.list.first().id + + model.setFiles(inputFiles) + model.play() + + model.state.get().let { state -> + assertEquals(3, state.currentFiles.size) + assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) + assertEquals(initialFileId, state.currentItemState.file.id) + } + + model.setFiles(inputFiles.copy(list = listOf(file(99)) + inputFiles.list)) + + model.state.get().let { state -> + assertEquals(4, state.currentFiles.size) + assertEquals(1, state.currentFiles.indexOf(state.currentItemState!!.file)) + assertEquals(initialFileId, state.currentItemState.file.id) + } + } + + @Test + @UiThreadTest + fun setFiles_repositionWhenCurrentRemoved() = runModelTest { model -> + val inputFiles = playbackFiles(3) + val initialFileId = inputFiles.list.first().id + + model.setFiles(inputFiles) + model.play() + + model.state.get().let { state -> + assertEquals(3, state.currentFiles.size) + assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) + assertEquals(initialFileId, state.currentItemState.file.id) + } + + model.setFiles(inputFiles.copy(list = inputFiles.list.filter { it.id != initialFileId })) + + model.state.get().let { state -> + assertEquals(2, state.currentFiles.size) + assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) + assertNotEquals(initialFileId, state.currentItemState.file.id) + } + } + + @Test + @UiThreadTest + fun playback_playPauseStop() = runModelTest { model -> + model.setFiles(playbackFiles(2)) + + model.play() + assertEquals(PlayerState.PLAYING, model.state.get().currentItemState!!.playerState) + + model.pause() + assertEquals(PlayerState.PAUSED, model.state.get().currentItemState!!.playerState) + } + + @Test + @UiThreadTest + fun playback_nextPrevious() = runModelTest { model -> + model.setFiles(playbackFiles(3)) + + model.play() + model.state.get().let { state -> + assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) + } + + model.playNext() + model.state.get().let { state -> + assertEquals(1, state.currentFiles.indexOf(state.currentItemState!!.file)) + } + + model.playPrevious() + model.state.get().let { state -> + assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) + } + } + + @Test + @UiThreadTest + fun playback_seekToPosition() = runModelTest { model -> + model.setFiles(playbackFiles(1)) + model.seekToPosition(5000L) + model.play() + assertEquals(5000L, model.state.get().currentItemState!!.currentTimeInMilliseconds) + } + + @Test + @UiThreadTest + fun settings_repeatAndShuffle() = runModelTest { model -> + model.setFiles(playbackFiles(2)) + model.setRepeatMode(RepeatMode.ALL) + model.setShuffle(true) + + model.state.get().let { state -> + assertEquals(RepeatMode.ALL, state.repeatMode) + assertTrue(state.shuffle) + } + + model.setRepeatMode(RepeatMode.SINGLE) + model.setShuffle(false) + + model.state.get().let { state -> + assertEquals(RepeatMode.SINGLE, state.repeatMode) + assertFalse(state.shuffle) + } + } + + @Test + @UiThreadTest + fun switchToFile_changesCurrentItem() = runModelTest { model -> + val inputFiles = playbackFiles(4) + + model.setFiles(inputFiles) + model.play() + + model.state.get().let { state -> + assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) + assertEquals(inputFiles.list.first().id, state.currentItemState.file.id) + } + + model.switchToFile(inputFiles.list.last()) + + model.state.get().let { state -> + assertEquals(3, state.currentFiles.indexOf(state.currentItemState!!.file)) + assertEquals(inputFiles.list.last().id, state.currentItemState.file.id) + } + } + + @Test + @UiThreadTest + fun setFilesFlow_emitsAndUpdates() = runModelTest { model -> + val filesFlow = MutableStateFlow(playbackFiles(2)) + model.setFilesFlow(filesFlow) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(2, model.state.get().currentFiles.size) + + filesFlow.tryEmit(playbackFiles(3)) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(3, model.state.get().currentFiles.size) + } + + private fun runModelTest(test: suspend TestScope.(PlaybackModel) -> Unit) = runTest { + model.start() + test(model) + model.release() + settings.reset() + } + + private fun file(id: Int) = PlaybackFile( + id = "id$id", + uri = "https://example.com/media$id.mp3", + name = "media$id.mp3", + mimeType = "audio/mpeg", + contentLength = 123456, + lastModified = System.currentTimeMillis() + id, + isFavorite = false + ) + + private fun playbackFiles(count: Int): PlaybackFiles { + val list = (0 until count).map { file(it) } + return PlaybackFiles(list, PlaybackFilesComparator.NONE) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt new file mode 100644 index 000000000000..095ab34aa9b4 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt @@ -0,0 +1,97 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import com.nextcloud.client.player.media3.common.MediaItemFactory +import com.nextcloud.client.player.media3.common.setExtras +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import androidx.media3.common.VideoSize as ExoVideoSize + +class PlaybackStateFactoryTest { + + private val stateFactory = PlaybackStateFactory() + private val itemFactory = MediaItemFactory() + + @Test + fun create_builds_PlaybackState_with_expected_fields() { + val player = mockk(relaxed = true) + + val file1 = createPlaybackFile("1") + val file2 = createPlaybackFile("2") + val item1 = itemFactory.create(file1) + val item2 = itemFactory.create(file2) + + val playerMetadata = MediaMetadata.Builder() + .setTitle("") // force fallback to file name without extension + .setArtist("Artist") + .setAlbumTitle("Album") + .setGenre("Rock") + .setRecordingYear(1999) + .setDescription("Desc") + .setArtworkData(byteArrayOf(1, 2, 3), 0) + .setExtras(file2) + .build() + + every { player.mediaItemCount } returns 2 + every { player.getMediaItemAt(0) } returns item1 + every { player.getMediaItemAt(1) } returns item2 + every { player.shuffleModeEnabled } returns true + every { player.repeatMode } returns Player.REPEAT_MODE_ALL + every { player.currentMediaItem } returns item2 + every { player.mediaMetadata } returns playerMetadata + every { player.currentPosition } returns 12_345L + every { player.duration } returns 54_321L + every { player.videoSize } returns ExoVideoSize(1920, 1080) + every { player.playbackState } returns Player.STATE_READY + every { player.playWhenReady } returns false + + val state = stateFactory.create(player).orElseThrow() + val currentItemState = state.currentItemState!! + val currentItemMetadata = currentItemState.metadata!! + val currentItemVideoSize = currentItemState.videoSize!! + + assertEquals(listOf(file1, file2), state.currentFiles) + assertEquals(RepeatMode.ALL, state.repeatMode) + assertTrue(state.shuffle) + assertEquals(file2, currentItemState.file) + assertEquals(PlayerState.PAUSED, currentItemState.playerState) + assertEquals("name2", currentItemMetadata.title) // fallback from empty title + assertEquals("Artist", currentItemMetadata.artist) + assertEquals("Album", currentItemMetadata.album) + assertEquals("Rock", currentItemMetadata.genre) + assertEquals(1999, currentItemMetadata.year) + assertEquals("Desc", currentItemMetadata.description) + assertArrayEquals(byteArrayOf(1, 2, 3), currentItemMetadata.artworkData) + assertNull(currentItemMetadata.artworkUri) + assertEquals(1920, currentItemVideoSize.width) + assertEquals(1080, currentItemVideoSize.height) + assertEquals(12_345L, currentItemState.currentTimeInMilliseconds) + assertEquals(54_321L, currentItemState.maxTimeInMilliseconds) + } + + private fun createPlaybackFile(id: String) = PlaybackFile( + id = id, + uri = "uri$id", + name = "name$id.mp3", + mimeType = "audio/mpeg", + contentLength = 0, + lastModified = 0, + isFavorite = false + ) +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt new file mode 100644 index 000000000000..f9703a4fca2f --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.common + +import com.nextcloud.client.player.model.file.PlaybackFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class MediaItemFactoryTest { + + private val factory = MediaItemFactory() + + @Test + fun create_builds_MediaItem_with_expected_fields() { + val file = PlaybackFile( + id = "123", + uri = "https://example.com/media.mp3", + name = "media.mp3", + mimeType = "audio/mpeg", + contentLength = 42L, + lastModified = 1736200000000L, + isFavorite = true + ) + + val item = factory.create(file) + + assertEquals(file.id, item.mediaId) + assertEquals(file.uri, item.localConfiguration?.uri.toString()) + assertEquals(file.mimeType, item.localConfiguration?.mimeType) + + val metadata = item.mediaMetadata + assertNotNull(metadata) + assertEquals(file, metadata.playbackFile) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt new file mode 100644 index 000000000000..32706605cfad --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt @@ -0,0 +1,163 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.common + +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +class TestPlayer(looper: Looper) : SimpleBasePlayer(looper) { + private val mediaItems = mutableListOf() + private var currentIndex = 0 + private var isPlaying = false + private var currentPositionMs: Long = 0L + private var repeatModeInternal: Int = REPEAT_MODE_OFF + private var shuffleEnabled: Boolean = false + + override fun getState(): State { + val commands = Player.Commands.Builder() + .addAllCommands() + .build() + + return State.Builder() + .setAvailableCommands(commands) + .setPlaybackState(if (isPlaying) STATE_READY else STATE_IDLE) + .setPlayWhenReady(isPlaying, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaylist(mediaItems.map { MediaItemData.Builder(it.mediaId).setMediaItem(it).build() }) + .setCurrentMediaItemIndex(currentIndex) + .setContentPositionMs(currentPositionMs) + .setRepeatMode(repeatModeInternal) + .setShuffleModeEnabled(shuffleEnabled) + .build() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + isPlaying = playWhenReady + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleStop(): ListenableFuture<*> { + isPlaying = false + currentPositionMs = 0L + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handlePrepare(): ListenableFuture<*> { + currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + currentPositionMs = 0L + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleRelease(): ListenableFuture<*> { + isPlaying = false + mediaItems.clear() + currentIndex = 0 + currentPositionMs = 0L + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> { + repeatModeInternal = repeatMode + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> { + shuffleEnabled = shuffleModeEnabled + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleSetMediaItems( + items: List, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture<*> { + mediaItems.clear() + mediaItems.addAll(items) + currentIndex = if (startIndex != C.INDEX_UNSET) startIndex else 0 + currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + currentPositionMs = if (startPositionMs != C.TIME_UNSET) startPositionMs else 0L + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleAddMediaItems(index: Int, newItems: List): ListenableFuture<*> { + mediaItems.addAll(index, newItems) + if (index <= currentIndex) { + currentIndex += newItems.size + } + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleMoveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int): ListenableFuture<*> { + val movingItems = mediaItems.subList(fromIndex, toIndex).toList() + mediaItems.subList(fromIndex, toIndex).clear() + mediaItems.addAll(newIndex, movingItems) + + currentIndex = when { + currentIndex in fromIndex until toIndex -> newIndex + (currentIndex - fromIndex) + currentIndex < fromIndex && newIndex <= currentIndex -> currentIndex + movingItems.size + currentIndex >= toIndex && newIndex < currentIndex -> currentIndex - movingItems.size + else -> currentIndex + } + currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleReplaceMediaItems(fromIndex: Int, toIndex: Int, newItems: List): ListenableFuture<*> { + mediaItems.subList(fromIndex, toIndex).clear() + mediaItems.addAll(fromIndex, newItems) + + if (currentIndex in fromIndex until toIndex) { + currentIndex = fromIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + currentPositionMs = 0L + } else if (currentIndex >= toIndex) { + currentIndex += (newItems.size - (toIndex - fromIndex)) + } + + currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleRemoveMediaItems(fromIndex: Int, toIndex: Int): ListenableFuture<*> { + mediaItems.subList(fromIndex, toIndex).clear() + + if (currentIndex in fromIndex until toIndex) { + currentIndex = fromIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + currentPositionMs = 0L + } else if (currentIndex >= toIndex) { + currentIndex -= (toIndex - fromIndex) + } + + currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + if (mediaItemIndex != C.INDEX_UNSET) { + currentIndex = mediaItemIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) + } + currentPositionMs = if (positionMs != C.TIME_UNSET) positionMs else 0L + invalidateState() + return Futures.immediateVoidFuture() + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt new file mode 100644 index 000000000000..19b287a7a8da --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.common + +import android.os.Looper +import androidx.media3.common.Player + +class TestPlayerFactory : PlayerFactory { + + override fun create(): Player = TestPlayer(Looper.getMainLooper()) +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt new file mode 100644 index 000000000000..066ee119abc8 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt @@ -0,0 +1,141 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.common + +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.test.annotation.UiThreadTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestPlayerTest { + + private lateinit var player: TestPlayer + + @Before + fun setUp() { + player = TestPlayer(Looper.getMainLooper()) + } + + @Test + @UiThreadTest + fun setMediaItems_applies_startIndex_and_startPosition_bounds() { + player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 2_000) + assertEquals(1, player.currentMediaItemIndex) + assertEquals(2_000L, player.currentPosition) + assertEquals(3, player.mediaItemCount) + } + + @Test + @UiThreadTest + fun play_pause_toggles_state() { + player.setMediaItems(listOf(item("a"))) + assertFalse(player.playWhenReady) + + player.play() + assertTrue(player.playWhenReady) + + player.pause() + assertFalse(player.playWhenReady) + } + + @Test + @UiThreadTest + fun addMediaItems_updates_indices_when_inserted_before_current() { + player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 0) + assertEquals(1, player.currentMediaItemIndex) + assertEquals("b", player.currentMediaItem?.mediaId) + + player.addMediaItems(0, listOf(item("x"), item("y"))) + assertEquals(3, player.currentMediaItemIndex) + assertEquals("b", player.currentMediaItem?.mediaId) + } + + @Test + @UiThreadTest + fun moveMediaItems_recomputes_current_index_inside_moved_block() { + player.setMediaItems(listOf(item("a"), item("b"), item("c"), item("d")), 2, 0) + assertEquals(2, player.currentMediaItemIndex) + assertEquals("c", player.currentMediaItem?.mediaId) + + player.moveMediaItems(1, 3, 2) + assertEquals(3, player.currentMediaItemIndex) + assertEquals("c", player.currentMediaItem?.mediaId) + } + + @Test + @UiThreadTest + fun replaceMediaItems_resets_position_when_current_replaced() { + player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 1_000) + player.replaceMediaItems(1, 2, listOf(item("x"), item("y"))) + assertEquals(1, player.currentMediaItemIndex) + assertEquals("x", player.currentMediaItem?.mediaId) + assertEquals(0L, player.currentPosition) + } + + @Test + @UiThreadTest + fun removeMediaItems_updates_current_index_and_resets_if_removed() { + player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 500) + player.removeMediaItems(1, 2) + assertEquals(1, player.currentMediaItemIndex) + assertEquals("c", player.currentMediaItem?.mediaId) + assertEquals(0L, player.currentPosition) + } + + @Test + @UiThreadTest + fun seekTo_updates_index_and_position() { + player.setMediaItems(listOf(item("a"), item("b"), item("c")), 0, 0) + player.seekTo(2, 12_345L) + assertEquals(2, player.currentMediaItemIndex) + assertEquals(12_345L, player.currentPosition) + } + + @Test + @UiThreadTest + fun shuffle_and_repeat_flags_reflected_in_state() { + player.setMediaItems(listOf(item("a"), item("b"))) + player.setShuffleModeEnabled(true) + player.repeatMode = Player.REPEAT_MODE_ALL + assertTrue(player.shuffleModeEnabled) + assertEquals(Player.REPEAT_MODE_ALL, player.repeatMode) + } + + @Test + @UiThreadTest + fun stop_and_release_clear_state() { + player.setMediaItems(listOf(item("a"), item("b")), 1, 100) + player.play() + player.stop() + assertEquals(0L, player.currentPosition) + assertFalse(player.playWhenReady) + + player.release() + assertEquals(0, player.mediaItemCount) + assertEquals(0, player.currentMediaItemIndex) + } + + @Test + @UiThreadTest + fun unset_start_values_use_defaults() { + player.setMediaItems(listOf(item("a"), item("b")), C.INDEX_UNSET, C.TIME_UNSET) + assertEquals(0, player.currentMediaItemIndex) + assertEquals(0L, player.currentPosition) + } + + private fun item(id: String) = MediaItem.Builder() + .setMediaId(id) + .setUri("https://example.com/$id.mp3") + .build() +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt new file mode 100644 index 000000000000..18ad0e06f970 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.controller + +import android.content.Context +import androidx.media3.session.MediaController +import com.nextcloud.client.player.media3.session.MediaSessionHolder +import kotlinx.coroutines.guava.await +import javax.inject.Provider + +class TestMediaControllerFactory( + private val context: Context, + private val sessionHolder: Provider +) : MediaControllerFactory { + + override suspend fun create(controllerListener: MediaController.Listener): MediaController = + MediaController.Builder(context, sessionHolder.get().getMediaSession().token) + .setListener(controllerListener) + .buildAsync() + .await() +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt new file mode 100644 index 000000000000..5204f7e56eb2 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt @@ -0,0 +1,132 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.datasource + +import android.net.Uri +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import com.nextcloud.client.player.model.file.getPlaybackUri +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.StreamMediaFileOperation +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifySequence +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.io.File +import java.io.IOException + +class DefaultDataSourceTest { + + private val delegate = mockk() + private val fileStore = mockk() + private val client = mockk() + private val streamOperationFactory = mockk() + + private lateinit var dataSource: DefaultDataSource + + @Before + fun setup() { + every { delegate.responseHeaders } returns emptyMap() + dataSource = DefaultDataSource(delegate, fileStore, client, streamOperationFactory) + } + + @Test + fun open_pass_through_when_uri_is_not_remote_file() { + val spec = DataSpec.Builder().setUri(Uri.parse("https://example.com/a.mp3")).build() + every { delegate.open(spec) } returns 123L + + val bytes = dataSource.open(spec) + + Assert.assertEquals(123L, bytes) + verify(exactly = 1) { delegate.open(spec) } + confirmVerified(delegate) + } + + @Test + fun open_opens_local_file_when_file_is_downloaded() { + val id = 42L + val tempFile = File.createTempFile("test_media", ".mp3") + val ocFile = OCFile("/remote/path/file.mp3").apply { + localId = id + setStoragePath(tempFile.absolutePath) + mimeType = "audio/mpeg" + } + + assert(tempFile.exists()) + + every { fileStore.getFileByLocalId(id) } returns ocFile + every { delegate.open(any()) } returns 555L + + val bytes = dataSource.open(remoteFileSpec(id)) + + Assert.assertEquals(555L, bytes) + verify { fileStore.getFileByLocalId(id) } + verify { delegate.open(match { it.uri == ocFile.storageUri }) } + confirmVerified(fileStore, delegate) + } + + @Test + fun open_opens_remote_stream_when_file_not_downloaded() { + val id = 7L + every { fileStore.getFileByLocalId(id) } returns null + + val streamOperation = mockk() + every { streamOperationFactory.create(id) } returns streamOperation + + val result = RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + result.data = arrayListOf("https://stream/url.m3u8") + every { streamOperation.execute(client) } returns result + every { delegate.open(any()) } returns 777L + + val bytes = dataSource.open(remoteFileSpec(id)) + + Assert.assertEquals(777L, bytes) + verifySequence { + fileStore.getFileByLocalId(id) + streamOperationFactory.create(id) + streamOperation.execute(client) + delegate.open(match { it.uri.toString() == "https://stream/url.m3u8" }) + } + } + + @Test + fun open_throws_IOException_when_remote_operation_fails() { + val id = 9L + every { fileStore.getFileByLocalId(id) } returns null + + val streamOperation = mockk() + every { streamOperationFactory.create(id) } returns streamOperation + + val result = mockk>() + every { result.isSuccess } returns false + every { result.exception } returns RuntimeException("boom") + every { streamOperation.execute(client) } returns result + + Assert.assertThrows(IOException::class.java) { + dataSource.open(remoteFileSpec(id)) + } + + verify { + fileStore.getFileByLocalId(id) + streamOperationFactory.create(id) + streamOperation.execute(client) + } + verify(exactly = 0) { delegate.open(any()) } + } + + private fun remoteFileSpec(id: Long) = DataSpec.Builder() + .setUri(getPlaybackUri(id)) + .build() +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt new file mode 100644 index 000000000000..e69eef68bcc2 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.resumption + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.owncloud.android.ui.fragment.SearchType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class PlaybackResumptionConfigStoreTest { + + private lateinit var store: PlaybackResumptionConfigStore + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + store = PlaybackResumptionConfigStore(context) + } + + @Test + fun save_and_load_returns_expected_config() { + store.saveConfig("123", 42L, PlaybackFileType.AUDIO, SearchType.NO_SEARCH) + val loaded = store.loadConfig() + requireNotNull(loaded) + assertEquals("123", loaded.currentFileId) + assertEquals(42L, loaded.folderId) + assertEquals(PlaybackFileType.AUDIO, loaded.fileType) + assertEquals(SearchType.NO_SEARCH, loaded.searchType) + } + + @Test + fun clear_and_load_returns_null() { + store.saveConfig("123", 42L, PlaybackFileType.AUDIO, SearchType.NO_SEARCH) + store.clear() + assertNull(store.loadConfig()) + } + + @Test + fun updateCurrentFileId_only_changes_that() { + store.saveConfig("123", 42L, PlaybackFileType.AUDIO, SearchType.NO_SEARCH) + store.updateCurrentFileId("999") + val loaded = store.loadConfig() + requireNotNull(loaded) + assertEquals("999", loaded.currentFileId) + assertEquals(42L, loaded.folderId) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt new file mode 100644 index 000000000000..a8dbb781e0ce --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt @@ -0,0 +1,141 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.resumption + +import androidx.media3.common.MediaItem +import com.nextcloud.client.player.media3.common.MediaItemFactory +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.nextcloud.client.player.model.file.PlaybackFiles +import com.nextcloud.client.player.model.file.PlaybackFilesComparator +import com.nextcloud.client.player.model.file.PlaybackFilesRepository +import com.owncloud.android.ui.fragment.SearchType +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Before +import org.junit.Test + +class PlaybackResumptionLauncherTest { + private val configStore = mockk() + private val filesRepository = mockk() + private val mediaItemFactory = mockk() + private val playbackModel = mockk(relaxed = true) + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var launcher: PlaybackResumptionLauncher + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + coEvery { playbackModel.start() } just Runs + + launcher = PlaybackResumptionLauncher( + playbackResumptionConfigStore = configStore, + playbackFilesRepository = filesRepository, + mediaItemFactory = mediaItemFactory, + playbackModel = playbackModel + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun launch_success_uses_config_and_sets_files_flow() = runTest { + val currentFileId = "2" + val config = PlaybackResumptionConfig( + currentFileId = currentFileId, + folderId = 42L, + fileType = PlaybackFileType.AUDIO, + searchType = SearchType.FAVORITE_SEARCH + ) + + every { configStore.loadConfig() } returns config + + val file1 = PlaybackFile("1", "uri1", "n1", "audio/mpeg", 10, 0, false) + val file2 = PlaybackFile("2", "uri2", "n2", "audio/mpeg", 11, 0, false) + val file3 = PlaybackFile("3", "uri3", "n3", "audio/mpeg", 12, 0, false) + + val firstEmission = PlaybackFiles(listOf(file1, file2, file3), PlaybackFilesComparator.FAVORITE) + val secondEmission = PlaybackFiles(listOf(file2, file3), PlaybackFilesComparator.FAVORITE) + + every { + filesRepository.observe(config.folderId, config.fileType, config.searchType) + } returns flow { + emit(firstEmission) + emit(secondEmission) + } + + listOf(file1, file2, file3, file2, file3).forEach { file -> + every { mediaItemFactory.create(file) } returns MediaItem.Builder() + .setMediaId(file.id) + .setUri(file.uri) + .build() + } + + val flowSlot = slot>() + every { playbackModel.setFilesFlow(capture(flowSlot)) } just Runs + + val result = launcher.launch() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { playbackModel.start() } + verify(exactly = 1) { playbackModel.setFilesFlow(any()) } + assert(result.mediaItems.size == 3) + assert(result.startIndex == 1) + assert(result.mediaItems[1].mediaId == currentFileId) + + val collectedSecond = mutableListOf() + val downstreamDispatcher = UnconfinedTestDispatcher(testScheduler) + withContext(downstreamDispatcher) { + flowSlot.captured.collect { collectedSecond += it } + } + + assert(collectedSecond.size == 1) + assert(collectedSecond.first().list.size == 2) + assert(collectedSecond.first().list.first().id == currentFileId) + } + + @Test + fun launch_fallback_when_config_null_returns_stub() = runTest { + every { configStore.loadConfig() } returns null + every { mediaItemFactory.create(any()) } answers { + val file = firstArg() + MediaItem.Builder().setMediaId(file.id).setUri(file.uri).build() + } + + val result = launcher.launch() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { playbackModel.start() } + assert(result.mediaItems.size == 1) + assert(result.mediaItems.first().mediaId == "0") + assert(result.startIndex == 0) + verify(exactly = 0) { playbackModel.setFilesFlow(any()) } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt new file mode 100644 index 000000000000..32d34a9ceb3c --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.resumption + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.client.player.model.file.PlaybackFileType +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class PlaybackResumptionPlayerListenerTest { + + private lateinit var store: PlaybackResumptionConfigStore + private lateinit var listener: PlaybackResumptionPlayerListener + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + store = PlaybackResumptionConfigStore(context) + store.saveConfig("oldId", 1L, PlaybackFileType.AUDIO, null) + listener = PlaybackResumptionPlayerListener(store) + } + + @Test + fun onMediaItemTransition_updates_id() { + val item = MediaItem.Builder().setMediaId("newId").build() + listener.onMediaItemTransition(item, 0) + val loaded = store.loadConfig() + requireNotNull(loaded) + assertEquals("newId", loaded.currentFileId) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt new file mode 100644 index 000000000000..ec2062ad01da --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt @@ -0,0 +1,119 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.media3.common.MediaMetadata +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.player.media3.common.setExtras +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future + +class MediaSessionBitmapLoaderTest { + + private lateinit var thumbnailLoader: ThumbnailLoader + private lateinit var bitmapLoader: MediaSessionBitmapLoader + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val playbackFile = PlaybackFile( + id = "123", + uri = "/remote.php/dav/files/user/song.mp3", + name = "song.mp3", + mimeType = "audio/mpeg", + contentLength = 1L, + lastModified = 0L, + isFavorite = false + ) + + @Before + fun setup() { + thumbnailLoader = mockk(relaxed = true) + bitmapLoader = MediaSessionBitmapLoader(context, thumbnailLoader) + } + + @Test + fun loads_from_artworkData() { + val expected = bitmap(0xFF00FF00.toInt()) + every { + thumbnailLoader.load(context, any(), playbackFile.id, any(), any()) + } returns completed(expected) + + val future = bitmapLoader.loadBitmapFromMetadata(metadata(artworkData = byteArrayOf(1, 2, 3)))!! + assertSame(expected, future.get()) + } + + @Test + fun loads_from_file_when_no_artwork() { + val expected = bitmap(0xFFFF0000.toInt()) + every { + thumbnailLoader.load(context, playbackFile, any(), any()) + } returns completed(expected) + + val future = bitmapLoader.loadBitmapFromMetadata(metadata())!! + assertSame(expected, future.get()) + } + + @Test + fun falls_back_to_default_icon_when_null() { + every { + thumbnailLoader.load(context, playbackFile, any(), any()) + } returns completed(null) + + val future = bitmapLoader.loadBitmapFromMetadata(metadata())!! + val result = future.get() + assertNotNull(result) + assertTrue(result.width > 0 && result.height > 0) + } + + @Test + fun returns_cached_future_for_same_request() { + val bitmap = bitmap(0xFF112233.toInt()) + every { + thumbnailLoader.load(context, any(), playbackFile.id, any(), any()) + } returns completed(bitmap) + + val metadata = metadata(artworkData = byteArrayOf(9)) + val future1 = bitmapLoader.loadBitmapFromMetadata(metadata)!! + val future2 = bitmapLoader.loadBitmapFromMetadata(metadata)!! + assertSame(future1, future2) + } + + @Test + fun different_artworkData_invalidates_cache() { + val bitmap1 = bitmap(0xFF010101.toInt()) + val bitmap2 = bitmap(0xFF020202.toInt()) + every { + thumbnailLoader.load(context, any(), playbackFile.id, any(), any()) + } returnsMany listOf(completed(bitmap1), completed(bitmap2)) + + val future1 = bitmapLoader.loadBitmapFromMetadata(metadata(artworkData = byteArrayOf(1)))!! + val future2 = bitmapLoader.loadBitmapFromMetadata(metadata(artworkData = byteArrayOf(2)))!! + assertNotSame(future1, future2) + assertNotSame(future1.get(), future2.get()) + } + + private fun bitmap(color: Int): Bitmap = Bitmap.createBitmap(intArrayOf(color), 1, 1, ARGB_8888) + + private fun completed(bitmap: Bitmap?): Future = CompletableFuture.completedFuture(bitmap) + + private fun metadata(artworkData: ByteArray? = null): MediaMetadata = MediaMetadata.Builder() + .setExtras(playbackFile) + .setArtworkData(artworkData, MediaMetadata.PICTURE_TYPE_FRONT_COVER) + .build() +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt new file mode 100644 index 000000000000..02173dc16f16 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import android.content.Context +import androidx.media3.session.MediaSession +import com.nextcloud.client.player.media3.common.PlayerFactory +import java.util.UUID + +class TestMediaSessionFactory(private val context: Context, private val playerFactory: PlayerFactory) : + MediaSessionFactory { + + override fun create(): MediaSession { + val player = playerFactory.create() + return MediaSession.Builder(context, player) + .setId(UUID.randomUUID().toString()) + .build() + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt new file mode 100644 index 000000000000..9a832ef475c8 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt @@ -0,0 +1,65 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.test.platform.app.InstrumentationRegistry +import com.nextcloud.client.player.model.state.RepeatMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class PlaybackSettingsTest { + + private lateinit var context: Context + private lateinit var preferences: SharedPreferences + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + preferences = context.getSharedPreferences("playback_settings", Context.MODE_PRIVATE) + } + + @Test + fun returns_default_values_when_empty() { + val settings = PlaybackSettings(context) + assertEquals(RepeatMode.ALL, settings.repeatMode) + assertFalse(settings.isShuffle) + settings.reset() + } + + @Test + fun setRepeatMode_persists_value() { + val settings = PlaybackSettings(context) + settings.setRepeatMode(RepeatMode.SINGLE) + val reloaded = PlaybackSettings(context) + assertEquals(RepeatMode.SINGLE, reloaded.repeatMode) + settings.reset() + } + + @Test + fun setShuffle_persists_value() { + val settings = PlaybackSettings(context) + settings.setShuffle(true) + val reloaded = PlaybackSettings(context) + assertTrue(reloaded.isShuffle) + settings.reset() + } + + @Test + fun falls_back_to_default_when_invalid_RepeatMode_is_stored() { + preferences.edit { putInt("repeat_mode_id", 999) } + val settings = PlaybackSettings(context) + assertEquals(RepeatMode.ALL, settings.repeatMode) + settings.reset() + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt new file mode 100644 index 000000000000..cfbf9e169339 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt @@ -0,0 +1,57 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackFileMapperTest { + + @Test + fun ocFile_maps_to_PlaybackFile() { + val ocFile = OCFile("/Documents/music/test.mp3").apply { + fileId = 123L + localId = 123L + fileLength = 42_000 + modificationTimestamp = 1_700_000_000_000L + mimeType = "audio/mpeg" + isFavorite = true + } + + val playback = ocFile.toPlaybackFile() + + assertEquals("123", playback.id) + assertEquals("remoteFile:///123", playback.uri) + assertEquals("test.mp3", playback.name) + assertEquals("audio/mpeg", playback.mimeType) + assertEquals(42_000, playback.contentLength) + assertEquals(1_700_000_000_000L, playback.lastModified) + assertEquals(true, playback.isFavorite) + } + + @Test + fun ocShare_maps_to_PlaybackFile_with_mimetype_fallback() { + val share = OCShare("/Shared/music/test.mp3").apply { + fileSource = 555L + mimetype = null + sharedDate = 1_700_111_222L + isFavorite = false + } + + val playback = share.toPlaybackFile() + + assertEquals("555", playback.id) + assertEquals("remoteFile:///555", playback.uri) + assertEquals("test.mp3", playback.name) + assertEquals("audio/mpeg", playback.mimeType) + assertEquals(1_700_111_222_000L, playback.lastModified) + assertEquals(false, playback.isFavorite) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt new file mode 100644 index 000000000000..6c5fa42d1f82 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import android.net.Uri +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import org.junit.Assert +import org.junit.Test + +class PlaybackFileUriMapperTest { + + @Test + fun getPlaybackUri_from_OCFile_uses_localId() { + val file = OCFile("/root/music/song.mp3").apply { + localId = 12345L + } + + val uri = file.getPlaybackUri() + Assert.assertEquals("remoteFile", uri.scheme) + Assert.assertEquals("12345", uri.lastPathSegment) + Assert.assertEquals(12345L, uri.getRemoteFileId()) + } + + @Test + fun getPlaybackUri_from_OCShare_uses_fileSource() { + val share = OCShare().apply { + fileSource = 9999L + } + + val uri = share.getPlaybackUri() + Assert.assertEquals("remoteFile", uri.scheme) + Assert.assertEquals("9999", uri.lastPathSegment) + Assert.assertEquals(9999L, uri.getRemoteFileId()) + } + + @Test + fun getRemoteFileId_returns_null_for_different_scheme() { + val other = Uri.parse("content://anything/123") + Assert.assertNull(other.getRemoteFileId()) + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt new file mode 100644 index 000000000000..ff321bb507e5 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt @@ -0,0 +1,311 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import android.content.ContentUris +import android.net.Uri +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.MimeTypeUtil +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class PlaybackFilesRepositoryTest { + private lateinit var storageManager: FileDataStorageManager + private lateinit var preferences: AppPreferences + private lateinit var repository: PlaybackFilesRepository + private lateinit var contentObserver: FakeContentObserver + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val favorites = listOf( + ocFile("/files/user/d1.docx", favorite = true), + ocFile("/files/user/a2.mp3", favorite = true), + ocFile("/files/user/v2.mkv", favorite = true), + ocFile("/files/user/i1.jpg", favorite = true), + ocFile("/files/user/a1.flac", favorite = true), + ocFile("/files/user/v1.mp4", favorite = true) + ) + + private val galleryItems = listOf( + ocFile("/files/user/i1.jpg", lastModified = 100L), + ocFile("/files/user/v1.mp4", lastModified = 200L), + ocFile("/files/user/i2.png", lastModified = 300L), + ocFile("/files/user/v2.mkv", lastModified = 400L) + ) + + private val shares = listOf( + ocShare("/files/user/d1.docx", shareDate = 100L), + ocShare("/files/user/a1.mp3", shareDate = 200L), + ocShare("/files/user/v1.mkv", shareDate = 300L), + ocShare("/files/user/i1.jpg", shareDate = 400L), + ocShare("/files/user/a2.flac", shareDate = 500L), + ocShare("/files/user/v2.mp4", shareDate = 600L) + ) + + private val folderItems = listOf( + ocFile("/files/user/folder/d1.docx", favorite = false), + ocFile("/files/user/folder/a1.mp3", favorite = false), + ocFile("/files/user/folder/v1.mkv", favorite = false), + ocFile("/files/user/folder/i1.jpg", favorite = false), + ocFile("/files/user/folder/a2.flac", favorite = true), + ocFile("/files/user/folder/v2.mp4", favorite = true) + ) + + private val folder = OCFile("/files/user/folder").apply { + localId = 1234L + mimeType = "DIR" + } + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + storageManager = mockk(relaxed = true) + preferences = mockk(relaxed = true) + contentObserver = FakeContentObserver() + repository = PlaybackFilesRepository( + storageManager, + preferences, + testDispatcher, + contentObserver + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun get_favorite_audio_playback_files() = testScope.runTest { + every { storageManager.favoriteFiles } returns favorites + val playbackFiles = repository.get(0L, PlaybackFileType.AUDIO, SearchType.FAVORITE_SEARCH) + assertEquals(listOf("a1.flac", "a2.mp3"), playbackFiles.list.map { it.name }) + } + + @Test + fun get_favorite_video_playback_files() = testScope.runTest { + every { storageManager.favoriteFiles } returns favorites + val playbackFiles = repository.get(0L, PlaybackFileType.VIDEO, SearchType.FAVORITE_SEARCH) + assertEquals(listOf("v1.mp4", "v2.mkv"), playbackFiles.list.map { it.name }) + } + + @Test + fun observe_favorite_audio_playback_files() { + val favorites = favorites.toMutableList() + every { storageManager.favoriteFiles } answers { favorites } + assertObserve( + flow = repository.observe(0L, PlaybackFileType.AUDIO, SearchType.FAVORITE_SEARCH), + contentUri = ProviderTableMeta.CONTENT_URI, + trigger1 = { favorites.add(ocFile("/files/user/a4.mp3")) }, + trigger2 = { favorites.add(ocFile("/files/user/a3.mp3")) }, + expected1 = listOf("a1.flac", "a2.mp3"), + expected2 = listOf("a1.flac", "a2.mp3", "a3.mp3", "a4.mp3") + ) + } + + @Test + fun observe_favorite_video_playback_files() { + val favorites = favorites.toMutableList() + every { storageManager.favoriteFiles } answers { favorites } + assertObserve( + flow = repository.observe(0L, PlaybackFileType.VIDEO, SearchType.FAVORITE_SEARCH), + contentUri = ProviderTableMeta.CONTENT_URI, + trigger1 = { favorites.add(ocFile("/files/user/v4.mp4")) }, + trigger2 = { favorites.add(ocFile("/files/user/v3.mp4")) }, + expected1 = listOf("v1.mp4", "v2.mkv"), + expected2 = listOf("v1.mp4", "v2.mkv", "v3.mp4", "v4.mp4") + ) + } + + @Test + fun get_gallery_video_playback_files() = testScope.runTest { + every { storageManager.allGalleryItems } returns galleryItems + val playbackFiles = repository.get(0L, PlaybackFileType.VIDEO, SearchType.GALLERY_SEARCH) + assertEquals(listOf("v2.mkv", "v1.mp4"), playbackFiles.list.map { it.name }) + } + + @Test + fun observe_gallery_video_playback_files() { + val galleryItems = galleryItems.toMutableList() + every { storageManager.allGalleryItems } answers { galleryItems } + assertObserve( + flow = repository.observe(0L, PlaybackFileType.VIDEO, SearchType.GALLERY_SEARCH), + contentUri = ProviderTableMeta.CONTENT_URI, + trigger1 = { galleryItems.add(ocFile("/files/user/v3.mp4", lastModified = 500L)) }, + trigger2 = { galleryItems.add(ocFile("/files/user/v4.mp4", lastModified = 600L)) }, + expected1 = listOf("v2.mkv", "v1.mp4"), + expected2 = listOf("v4.mp4", "v3.mp4", "v2.mkv", "v1.mp4") + ) + } + + @Test + fun get_shared_audio_playback_files() = testScope.runTest { + every { storageManager.shares } returns shares + val playbackFiles = repository.get(0L, PlaybackFileType.AUDIO, SearchType.SHARED_FILTER) + assertEquals(listOf("a2.flac", "a1.mp3"), playbackFiles.list.map { it.name }) + } + + @Test + fun get_shared_video_playback_files() = testScope.runTest { + every { storageManager.shares } returns shares + val playbackFiles = repository.get(0L, PlaybackFileType.VIDEO, SearchType.SHARED_FILTER) + assertEquals(listOf("v2.mp4", "v1.mkv"), playbackFiles.list.map { it.name }) + } + + @Test + fun observe_shared_audio_playback_files() { + val shares = shares.toMutableList() + every { storageManager.shares } answers { shares } + assertObserve( + flow = repository.observe(0L, PlaybackFileType.AUDIO, SearchType.SHARED_FILTER), + contentUri = ProviderTableMeta.CONTENT_URI_SHARE, + trigger1 = { shares.add(ocShare("/files/user/a3.mp3", 700L)) }, + trigger2 = { shares.add(ocShare("/files/user/a4.mp3", 800L)) }, + expected1 = listOf("a2.flac", "a1.mp3"), + expected2 = listOf("a4.mp3", "a3.mp3", "a2.flac", "a1.mp3") + ) + } + + @Test + fun observe_shared_video_playback_files() { + val shares = shares.toMutableList() + every { storageManager.shares } answers { shares } + assertObserve( + flow = repository.observe(0L, PlaybackFileType.VIDEO, SearchType.SHARED_FILTER), + contentUri = ProviderTableMeta.CONTENT_URI_SHARE, + trigger1 = { shares.add(ocShare("/files/user/v3.mp4", 700L)) }, + trigger2 = { shares.add(ocShare("/files/user/v4.mp4", 800L)) }, + expected1 = listOf("v2.mp4", "v1.mkv"), + expected2 = listOf("v4.mp4", "v3.mp4", "v2.mp4", "v1.mkv") + ) + } + + @Test + fun get_folder_audio_playback_files() = testScope.runTest { + mockFolder(folder, folderItems) + val playbackFiles = repository.get(folder.localId, PlaybackFileType.AUDIO, null) + assertEquals(listOf("a2.flac", "a1.mp3"), playbackFiles.list.map { it.name }) + } + + @Test + fun get_folder_video_playback_files() = testScope.runTest { + mockFolder(folder, folderItems) + val playbackFiles = repository.get(folder.localId, PlaybackFileType.VIDEO, null) + assertEquals(listOf("v2.mp4", "v1.mkv"), playbackFiles.list.map { it.name }) + } + + @Test + fun observe_folder_audio_playback_files() { + val folderItems = folderItems.toMutableList() + mockFolder(folder, folderItems) + assertObserve( + flow = repository.observe(folder.localId, PlaybackFileType.AUDIO, null), + contentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, folder.localId), + trigger1 = { folderItems.add(ocFile("/files/user/folder/a3.mp3", favorite = true)) }, + trigger2 = { folderItems.add(ocFile("/files/user/folder/a4.mp3", favorite = false)) }, + expected1 = listOf("a2.flac", "a1.mp3"), + expected2 = listOf("a2.flac", "a3.mp3", "a1.mp3", "a4.mp3") + ) + } + + @Test + fun observe_folder_video_playback_files() { + val folderItems = folderItems.toMutableList() + mockFolder(folder, folderItems) + assertObserve( + flow = repository.observe(folder.localId, PlaybackFileType.VIDEO, null), + contentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, folder.localId), + trigger1 = { folderItems.add(ocFile("/files/user/folder/v3.mp4", favorite = false)) }, + trigger2 = { folderItems.add(ocFile("/files/user/folder/v4.mp4", favorite = true)) }, + expected1 = listOf("v2.mp4", "v1.mkv"), + expected2 = listOf("v2.mp4", "v4.mp4", "v1.mkv", "v3.mp4") + ) + } + + private fun assertObserve( + flow: Flow, + contentUri: Uri, + trigger1: () -> Unit, + trigger2: () -> Unit, + expected1: List, + expected2: List + ) = testScope.runTest { + val emissions = mutableListOf() + val job = launch { flow.toList(emissions) } + + advanceUntilIdle() + assertEquals(expected1, emissions[0].list.map { it.name }) + + trigger1() + contentObserver.emit(contentUri) + delay(100L) + trigger2() + contentObserver.emit(contentUri) + + advanceUntilIdle() + assertEquals(2, emissions.size) + assertEquals(expected2, emissions[1].list.map { it.name }) + + job.cancel() + } + + private fun mockFolder(folder: OCFile, items: List) { + every { storageManager.getFileById(folder.localId) } returns folder + every { storageManager.getFolderContent(folder, any()) } returns items + every { preferences.getSortOrderByFolder(folder) } returns FileSortOrder.SORT_A_TO_Z + } + + private fun ocFile(path: String, lastModified: Long = 0L, favorite: Boolean = false): OCFile = OCFile(path).apply { + localId = path.hashCode().toLong() + mimeType = MimeTypeUtil.getMimeTypeFromPath(path) + modificationTimestamp = lastModified + isFavorite = favorite + } + + private fun ocShare(path: String, shareDate: Long): OCShare = OCShare(path).apply { + fileSource = path.hashCode().toLong() + mimetype = MimeTypeUtil.getMimeTypeFromPath(path) + sharedDate = shareDate + } + + class FakeContentObserver : (Uri, Boolean) -> Flow { + private val map = mutableMapOf>() + + override fun invoke(uri: Uri, notify: Boolean): Flow = map.getOrPut(uri) { + MutableSharedFlow(extraBufferCapacity = 16) + } + + fun emit(uri: Uri) { + map[uri]?.tryEmit(true) + } + } +} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt new file mode 100644 index 000000000000..3e904e08e5b6 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt @@ -0,0 +1,174 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.control + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import java.util.Optional + +class PlayerControlViewTest { + + private lateinit var playbackModel: PlaybackModel + private lateinit var view: PlayerControlView + private lateinit var listenerSlot: CapturingSlot + + @Before + fun setup() { + playbackModel = mockk(relaxed = true) + listenerSlot = slot() + + every { playbackModel.addListener(capture(listenerSlot)) } returns Unit + every { playbackModel.removeListener(any()) } returns Unit + every { playbackModel.state } returns Optional.empty() + + val context = ApplicationProvider.getApplicationContext() + view = PlayerControlView(context, injectedPlaybackModel = playbackModel) + view.onStart() + } + + @Test + fun playPause_whenPlaying_invokesPause() { + val files = listOf(mockFile()) + val itemState = itemState(PlayerState.PLAYING, 1_000, 5_000) + pushState(playbackState(files = files, itemState = itemState)) + view.binding.ivPlayPause.performClick() + verify { playbackModel.pause() } + } + + @Test + fun playPause_whenPaused_invokesPlay() { + val files = listOf(mockFile()) + val itemState = itemState(PlayerState.PAUSED, 2_000, 6_000) + pushState(playbackState(files = files, itemState = itemState)) + view.binding.ivPlayPause.performClick() + verify { playbackModel.play() } + } + + @Test + fun repeat_clickFromAll_setsSingle() { + pushState(playbackState(repeat = RepeatMode.ALL)) + view.binding.ivRepeat.performClick() + verify { playbackModel.setRepeatMode(RepeatMode.SINGLE) } + } + + @Test + fun repeat_clickFromSingle_setsAll() { + pushState(playbackState(repeat = RepeatMode.SINGLE)) + view.binding.ivRepeat.performClick() + verify { playbackModel.setRepeatMode(RepeatMode.ALL) } + } + + @Test + fun shuffle_clickFromOff_enablesShuffle() { + pushState(playbackState(shuffle = false)) + view.binding.ivRandom.performClick() + verify { playbackModel.setShuffle(true) } + } + + @Test + fun shuffle_clickFromOn_disablesShuffle() { + pushState(playbackState(shuffle = true)) + view.binding.ivRandom.performClick() + verify { playbackModel.setShuffle(false) } + } + + @Test + fun nextPrevious_enablement_singleItem() { + val files = listOf(mockFile()) + val itemState = itemState(PlayerState.PLAYING, 500, 2_000) + pushState(playbackState(files = files, itemState = itemState)) + assert(!view.binding.ivNext.isEnabled) + assert(view.binding.ivPrevious.isEnabled) + } + + @Test + fun nextPrevious_enablement_multipleItems() { + val files = listOf(mockFile(), mockFile()) + val itemState = itemState(PlayerState.PAUSED, 500, 2_000) + pushState(playbackState(files = files, itemState = itemState)) + assert(view.binding.ivNext.isEnabled) + assert(view.binding.ivPrevious.isEnabled) + } + + @Test + fun nextPrevious_disabledWhenNoCurrentItem() { + val files = listOf(mockFile(), mockFile()) + pushState(playbackState(files = files, itemState = null)) + assert(!view.binding.ivNext.isEnabled) + assert(!view.binding.ivPrevious.isEnabled) + } + + @Test + fun progressBar_indeterminateWhenNoItem() { + pushState(playbackState(itemState = null)) + assert(view.binding.progressBar.progress == 0) + assert(view.binding.tvElapsed.text.toString() == "--:--") + assert(view.binding.tvTotalTime.text.toString() == "--:--") + } + + @Test + fun progressBar_rendersValuesUnderHour() { + val itemState = itemState(PlayerState.PLAYING, current = 65_000, max = 125_000) + pushState(playbackState(itemState = itemState)) + assert(view.binding.progressBar.max == 125_000) + assert(view.binding.progressBar.progress == 65_000) + assert(view.binding.tvTotalTime.text.toString() == "02:05") + assert(view.binding.tvElapsed.text.toString() == "01:05") + } + + @Test + fun progressBar_rendersValuesOverHour() { + val itemState = itemState(PlayerState.PLAYING, current = 605_000, max = 3_726_000) + pushState(playbackState(itemState = itemState)) + assert(view.binding.progressBar.max == 3_726_000) + assert(view.binding.progressBar.progress == 605_000) + assert(view.binding.tvTotalTime.text.toString() == "01:02:06") + assert(view.binding.tvElapsed.text.toString() == "00:10:05") + } + + private fun mockFile(): PlaybackFile = mockk(relaxed = true) + + private fun itemState(state: PlayerState, current: Long, max: Long) = PlaybackItemState( + file = mockFile(), + playerState = state, + metadata = null, + videoSize = null, + currentTimeInMilliseconds = current, + maxTimeInMilliseconds = max + ) + + private fun playbackState( + files: List = emptyList(), + itemState: PlaybackItemState? = null, + repeat: RepeatMode = RepeatMode.ALL, + shuffle: Boolean = false + ) = PlaybackState( + currentFiles = files, + currentItemState = itemState, + repeatMode = repeat, + shuffle = shuffle + ) + + private fun pushState(state: PlaybackState) { + every { playbackModel.state } returns Optional.of(state) + listenerSlot.captured.onPlaybackUpdate(state) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9b12f3b683f7..30e4988109af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -391,9 +391,14 @@ android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="false" android:theme="@style/Theme.ownCloud.Media" /> + @@ -402,6 +407,13 @@ + + + + + + diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt index ed5aab7755af..a30fd9ad2024 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -85,6 +85,13 @@ interface FileDao { @Query("SELECT * FROM filelist WHERE file_owner = :fileOwner ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") fun getAllFiles(fileOwner: String): List + @Query( + "SELECT * FROM filelist WHERE favorite = 1" + + " AND file_owner = :fileOwner" + + " ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}" + ) + fun getFavoriteFiles(fileOwner: String): List + @Query("SELECT * FROM filelist WHERE path LIKE :pathPattern AND file_owner = :fileOwner ORDER BY path ASC") fun getFolderWithDescendants(pathPattern: String, fileOwner: String): List diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.java b/app/src/main/java/com/nextcloud/client/di/AppComponent.java index 8e1f599e5723..263b4b3f3fbf 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.java +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.java @@ -23,6 +23,7 @@ import com.nextcloud.client.media.BackgroundPlayerService; import com.nextcloud.client.network.NetworkModule; import com.nextcloud.client.onboarding.OnboardingModule; +import com.nextcloud.client.player.PlayerModule; import com.nextcloud.client.preferences.PreferencesModule; import com.owncloud.android.MainApp; import com.owncloud.android.media.MediaControlView; @@ -53,6 +54,7 @@ DatabaseModule.class, DispatcherModule.class, VariantModule.class, + PlayerModule.class, }) @Singleton public interface AppComponent { diff --git a/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt new file mode 100644 index 000000000000..6e9245f7f44d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt @@ -0,0 +1,123 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import com.nextcloud.client.player.media3.ExoPlayerFactory +import com.nextcloud.client.player.media3.Media3PlaybackModel +import com.nextcloud.client.player.media3.PlaybackService +import com.nextcloud.client.player.media3.common.PlayerFactory +import com.nextcloud.client.player.media3.controller.DefaultMediaControllerFactory +import com.nextcloud.client.player.media3.controller.MediaControllerFactory +import com.nextcloud.client.player.media3.datasource.DefaultDataSourceFactory +import com.nextcloud.client.player.media3.session.DefaultMediaSessionFactory +import com.nextcloud.client.player.media3.session.MediaSessionFactory +import com.nextcloud.client.player.media3.session.MediaSessionHolder +import com.nextcloud.client.player.model.GlideThumbnailLoader +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.error.DefaultPlaybackErrorStrategy +import com.nextcloud.client.player.model.error.PlaybackErrorStrategy +import com.nextcloud.client.player.ui.PlayerActivity +import com.nextcloud.client.player.ui.PlayerProgressIndicator +import com.nextcloud.client.player.ui.audio.AudioFileFragment +import com.nextcloud.client.player.ui.audio.AudioPlayerView +import com.nextcloud.client.player.ui.control.PlayerControlView +import com.nextcloud.client.player.ui.video.VideoFileFragment +import com.nextcloud.client.player.ui.video.VideoPlayerView +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.android.ContributesAndroidInjector +import java.io.File +import javax.inject.Singleton + +private const val PLAYER_CACHE_DIR_NAME = "player" +private const val PLAYER_CACHE_SIZE = 300 * 1024 * 1024L + +@Module(includes = [PlayerModule.Bindings::class, PlayerModule.AndroidInjector::class]) +class PlayerModule { + + @Provides + @Singleton + @UnstableApi + fun provideCache(context: Context): Cache = SimpleCache( + File(context.cacheDir, PLAYER_CACHE_DIR_NAME), + LeastRecentlyUsedCacheEvictor(PLAYER_CACHE_SIZE) + ) + + @Module + abstract class Bindings { + + @Binds + @Singleton + @UnstableApi + abstract fun playbackModel(model: Media3PlaybackModel): PlaybackModel + + @Binds + @Singleton + @UnstableApi + abstract fun mediaSessionHolder(playbackModel: Media3PlaybackModel): MediaSessionHolder + + @Binds + @UnstableApi + abstract fun mediaSessionFactory(sessionFactory: DefaultMediaSessionFactory): MediaSessionFactory + + @Binds + @UnstableApi + abstract fun mediaControllerFactory(controllerFactory: DefaultMediaControllerFactory): MediaControllerFactory + + @Binds + @UnstableApi + abstract fun playerFactory(playbackFactory: ExoPlayerFactory): PlayerFactory + + @Binds + @UnstableApi + abstract fun dataSourceFactory(dataSourceFactory: DefaultDataSourceFactory): DataSource.Factory + + @Binds + abstract fun playbackErrorStrategy(strategy: DefaultPlaybackErrorStrategy): PlaybackErrorStrategy + + @Binds + abstract fun thumbnailLoader(thumbnailLoader: GlideThumbnailLoader): ThumbnailLoader + } + + @Module + abstract class AndroidInjector { + + @UnstableApi + @ContributesAndroidInjector + abstract fun playbackService(): PlaybackService + + @ContributesAndroidInjector + abstract fun playerActivity(): PlayerActivity + + @ContributesAndroidInjector + abstract fun audioPlayerView(): AudioPlayerView + + @ContributesAndroidInjector + abstract fun videoPlayerView(): VideoPlayerView + + @ContributesAndroidInjector + abstract fun playerControlView(): PlayerControlView + + @ContributesAndroidInjector + abstract fun playerProgressIndicator(): PlayerProgressIndicator + + @ContributesAndroidInjector + abstract fun audioFileFragment(): AudioFileFragment + + @ContributesAndroidInjector + abstract fun videoFileFragment(): VideoFileFragment + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt new file mode 100644 index 000000000000..405bf8dd61a0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import android.content.Context +import androidx.media3.common.AudioAttributes +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.nextcloud.client.player.media3.common.PlayerFactory +import javax.inject.Inject + +private const val FIVE_SECONDS_IN_MILLIS = 5000L + +@UnstableApi +class ExoPlayerFactory @Inject constructor( + private val context: Context, + private val dataSourceFactory: DataSource.Factory +) : PlayerFactory { + + override fun create(): Player = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setSeekForwardIncrementMs(FIVE_SECONDS_IN_MILLIS) + .build() +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt new file mode 100644 index 000000000000..565c5a69ff9e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt @@ -0,0 +1,224 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import android.view.SurfaceView +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.MediaSession +import com.nextcloud.client.player.media3.common.MediaItemFactory +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.media3.controller.MediaControllerFactory +import com.nextcloud.client.player.media3.controller.indexOfFirst +import com.nextcloud.client.player.media3.controller.setRepeatMode +import com.nextcloud.client.player.media3.controller.updateMediaItems +import com.nextcloud.client.player.media3.session.MediaSessionFactory +import com.nextcloud.client.player.media3.session.MediaSessionHolder +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.PlaybackModelCompositeListener +import com.nextcloud.client.player.model.PlaybackSettings +import com.nextcloud.client.player.model.error.PlaybackErrorStrategy +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.PlaybackFiles +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.util.PeriodicAction +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.Optional +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@UnstableApi +@Suppress("LongParameterList") +class Media3PlaybackModel @Inject constructor( + private val stateFactory: PlaybackStateFactory, + private val mediaSessionFactory: MediaSessionFactory, + private val controllerFactory: MediaControllerFactory, + private val playbackSettings: PlaybackSettings, + private val mediaItemFactory: MediaItemFactory, + private val playbackErrorStrategy: PlaybackErrorStrategy +) : PlaybackModel, + MediaSessionHolder { + + companion object { + private const val CHECK_PROGRESS_INTERVAL = 1000L + } + + private val modelCompositeListener = PlaybackModelCompositeListener() + + private val checkProgressPeriodicAction = PeriodicAction(CHECK_PROGRESS_INTERVAL) { + state.ifPresent(modelCompositeListener::onPlaybackUpdate) + } + + private val playerListener = PlaybackModelPlayerListener( + checkProgressPeriodicAction, + this::onPlaybackUpdate, + this::onPlaybackError + ) + + private val controllerListener = object : MediaController.Listener { + override fun onDisconnected(controller: MediaController) { + controller.removeListener(playerListener) + controllerScope?.cancel() + checkProgressPeriodicAction.stop() + state.ifPresent(modelCompositeListener::onPlaybackUpdate) + } + } + + private var controllerScope: CoroutineScope? = null + private var controller: Player? = null + + private var mediaSession: MediaSession? = null + + override val state: Optional + get() { + return stateFactory.create(controller) + } + + override fun getMediaSession(): MediaSession = mediaSession ?: mediaSessionFactory.create().also { + mediaSession = it + } + + override suspend fun start() { + controller = controllerFactory.create(controllerListener).apply { + addListener(playerListener) + setRepeatMode(playbackSettings.repeatMode) + shuffleModeEnabled = playbackSettings.isShuffle + controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + } + } + + override fun setFilesFlow(filesFlow: Flow) { + controllerScope?.launch { + filesFlow + .catch { + modelCompositeListener.onPlaybackError(it) + release() + } + .collectLatest { setFiles(it) } + } + } + + override fun setFiles(files: PlaybackFiles) { + if (files.list.isEmpty()) { + release() + return + } + + controller?.let { controller -> + val currentFile = controller.currentMediaItem?.mediaMetadata?.playbackFile + val mediaItems = files.list.map(mediaItemFactory::create) + + if (currentFile == null) { + controller.setMediaItems(mediaItems) + } else if (files.list.any { it.id == currentFile.id }) { + controller.updateMediaItems(mediaItems) + } else { + val nextFileIndex = getNextFileIndex(files, currentFile) + controller.setMediaItems(mediaItems, nextFileIndex, 0) + } + + controller.prepare() + } + } + + private fun getNextFileIndex(files: PlaybackFiles, currentFile: PlaybackFile): Int = (files.list + currentFile) + .sortedWith(files.comparator) + .indexOfFirst { it.id == currentFile.id } + .let { if (it in 0..files.list.lastIndex) it else 0 } + + override fun release() { + controller?.release() + mediaSession?.player?.release() + mediaSession?.release() + mediaSession = null + } + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + controller?.setVideoSurfaceView(surfaceView) + } + + override fun addListener(listener: PlaybackModel.Listener) { + modelCompositeListener.addListener(listener) + } + + override fun removeListener(listener: PlaybackModel.Listener) { + modelCompositeListener.removeListener(listener) + } + + override fun play() { + controller?.run { + prepare() + play() + } + } + + override fun pause() { + controller?.pause() + } + + override fun playNext() { + controller?.run { + seekToNextMediaItem() + prepare() + } + } + + override fun playPrevious() { + controller?.run { + seekToPreviousMediaItem() + prepare() + } + } + + override fun seekToPosition(positionInMilliseconds: Long) { + controller?.seekTo(positionInMilliseconds) + } + + override fun setRepeatMode(repeatMode: RepeatMode) { + playbackSettings.setRepeatMode(repeatMode) + controller?.setRepeatMode(repeatMode) + } + + override fun setShuffle(shuffle: Boolean) { + playbackSettings.setShuffle(shuffle) + controller?.shuffleModeEnabled = shuffle + } + + override fun switchToFile(file: PlaybackFile) { + controller?.run { + val mediaItemIndex = indexOfFirst { it.mediaId == file.id } + if (mediaItemIndex >= 0 && mediaItemIndex != currentMediaItemIndex) { + seekToDefaultPosition(mediaItemIndex) + prepare() + } + } + } + + private fun onPlaybackUpdate() { + state.ifPresent(modelCompositeListener::onPlaybackUpdate) + } + + private fun onPlaybackError(error: Throwable) { + modelCompositeListener.onPlaybackError(error) + state.ifPresent { state -> + if (playbackErrorStrategy.switchToNextSource(error, state)) { + playNext() + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt new file mode 100644 index 000000000000..e0bacb9a281a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import android.content.Context +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.DefaultMediaNotificationProvider +import com.nextcloud.client.player.media3.common.playbackFile + +@UnstableApi +class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { + + override fun getNotificationContentTitle(metadata: MediaMetadata): CharSequence? = + if (metadata.title.isNullOrEmpty()) { + metadata.playbackFile?.getNameWithoutExtension() + } else { + metadata.title + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt new file mode 100644 index 000000000000..6e619585ba1b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.exoplayer.ExoPlaybackException +import androidx.media3.exoplayer.source.UnrecognizedInputFormatException +import com.nextcloud.client.player.model.error.SourceException +import com.nextcloud.client.player.util.PeriodicAction + +class PlaybackModelPlayerListener( + private val checkProgressPeriodicAction: PeriodicAction, + private val onPlaybackUpdate: () -> Unit, + private val onPlaybackError: (Throwable) -> Unit +) : Player.Listener { + + companion object { + private const val BROKEN_SOURCE_ERROR_CODE: Int = 416 + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + onPlaybackUpdate() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + onPlaybackUpdate() + } + + override fun onTracksChanged(tracks: Tracks) { + onPlaybackUpdate() + } + + override fun onPlaybackStateChanged(playbackState: Int) { + onPlaybackUpdate() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + onPlaybackUpdate() + if (isPlaying) { + checkProgressPeriodicAction.start() + } else { + checkProgressPeriodicAction.stop() + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + onPlaybackUpdate() + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + onPlaybackUpdate() + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + onPlaybackUpdate() + } + + @UnstableApi + override fun onPlayerError(error: PlaybackException) { + if (error is ExoPlaybackException && error.type == ExoPlaybackException.TYPE_SOURCE) { + onPlaybackError(error.toSourceException()) + } else { + onPlaybackError(error) + } + } + + @UnstableApi + private fun ExoPlaybackException.toSourceException(): SourceException = + if (sourceException is InvalidResponseCodeException) { + SourceException((sourceException as InvalidResponseCodeException).responseCode) + } else if (cause != null && cause is UnrecognizedInputFormatException) { + SourceException(BROKEN_SOURCE_ERROR_CODE) + } else { + SourceException() + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt new file mode 100644 index 000000000000..7cba91115f14 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import android.content.Intent +import android.os.IBinder +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ControllerInfo +import androidx.media3.session.MediaSessionService +import com.nextcloud.client.player.media3.session.MediaSessionActivityFactory +import com.nextcloud.client.player.media3.session.MediaSessionHolder +import dagger.android.AndroidInjection +import javax.inject.Inject + +@UnstableApi +class PlaybackService : MediaSessionService() { + + @Inject + lateinit var mediaSessionHolder: MediaSessionHolder + + @Inject + lateinit var mediaSessionActivityFactory: MediaSessionActivityFactory + + private var bindingCount: Int = 0 + + override fun onCreate() { + super.onCreate() + AndroidInjection.inject(this) + setMediaNotificationProvider(MediaNotificationProvider(this)) + } + + override fun onGetSession(controllerInfo: ControllerInfo): MediaSession? = mediaSessionHolder.getMediaSession() + + override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { + val currentMediaItem = session.player.currentMediaItem + mediaSessionActivityFactory.create(currentMediaItem)?.let(session::setSessionActivity) + super.onUpdateNotification(session, startInForegroundRequired) + } + + override fun onBind(intent: Intent?): IBinder? { + val result = super.onBind(intent) + if (result != null) { + bindingCount++ + } + return result + } + + override fun onUnbind(intent: Intent?): Boolean { + bindingCount-- + if (bindingCount == 0) { + stopSelf() + } + return super.onUnbind(intent) + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + mediaSessionHolder.release() + stopSelf() + } + + override fun onDestroy() { + mediaSessionHolder.release() + super.onDestroy() + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt new file mode 100644 index 000000000000..2df4f9e53d37 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt @@ -0,0 +1,84 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3 + +import androidx.media3.common.Player +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemMetadata +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.model.state.VideoSize +import java.util.Optional +import javax.inject.Inject + +class PlaybackStateFactory @Inject constructor() { + + fun create(player: Player?): Optional { + if (player == null) { + return Optional.empty() + } + val state = PlaybackState( + currentFiles = player.getCurrentFiles(), + currentItemState = player.getCurrentItemState(), + repeatMode = player.mapRepeatMode(), + shuffle = player.shuffleModeEnabled + ) + return Optional.of(state) + } + + private fun Player.getCurrentFiles(): List = buildList { + for (i in 0 until mediaItemCount) { + val mediaItem = getMediaItemAt(i) + val playbackFile = mediaItem.mediaMetadata.playbackFile + playbackFile?.let(::add) + } + } + + private fun Player.getCurrentItemState(): PlaybackItemState? { + val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null + return PlaybackItemState( + file = currentFile, + playerState = mapPlayerState(), + metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null, + videoSize = mapVideoSize(), + currentTimeInMilliseconds = currentPosition, + maxTimeInMilliseconds = duration + ) + } + + private fun Player.mapPlayerState(): PlayerState = when (playbackState) { + Player.STATE_IDLE -> PlayerState.IDLE + Player.STATE_ENDED -> PlayerState.COMPLETED + Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + else -> PlayerState.NONE + } + + private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata( + title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(), + artist = mediaMetadata.artist, + album = mediaMetadata.albumTitle, + genre = mediaMetadata.genre, + year = mediaMetadata.recordingYear, + description = mediaMetadata.description, + artworkData = mediaMetadata.artworkData, + artworkUri = mediaMetadata.artworkUri?.toString() + ) + + private fun Player.mapVideoSize(): VideoSize? = videoSize + .takeIf { it.width > 0 && it.height > 0 } + ?.let { VideoSize(width = it.width, height = it.height) } + + private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) { + Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> RepeatMode.OFF + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt new file mode 100644 index 000000000000..1c0d84ae7921 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.common + +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import com.nextcloud.client.player.model.file.PlaybackFile +import javax.inject.Inject + +class MediaItemFactory @Inject constructor() { + + fun create(file: PlaybackFile): MediaItem = MediaItem + .Builder() + .setMediaId(file.id) + .setUri(file.uri) + .setMediaMetadata(createMetadata(file)) + .setMimeType(file.mimeType) + .build() + + private fun createMetadata(file: PlaybackFile): MediaMetadata = MediaMetadata + .Builder() + .setExtras(file) + .build() +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt new file mode 100644 index 000000000000..b007d1b94b70 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.common + +import android.os.Bundle +import androidx.media3.common.MediaMetadata +import com.nextcloud.client.player.model.file.PlaybackFile + +private const val PLAYBACK_FILE_KEY = "playback_file" + +fun MediaMetadata.Builder.setExtras(playbackFile: PlaybackFile): MediaMetadata.Builder = setExtras( + Bundle().apply { + putSerializable(PLAYBACK_FILE_KEY, playbackFile) + } +) + +val MediaMetadata.playbackFile: PlaybackFile? + get() = extras?.getSerializable(PLAYBACK_FILE_KEY) as? PlaybackFile diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt new file mode 100644 index 000000000000..ea6eab84f46f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.common + +import androidx.media3.common.Player + +interface PlayerFactory { + fun create(): Player +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt new file mode 100644 index 000000000000..08c2322805e2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.controller + +import android.content.ComponentName +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.nextcloud.client.player.media3.PlaybackService +import kotlinx.coroutines.guava.await +import javax.inject.Inject + +@UnstableApi +class DefaultMediaControllerFactory @Inject constructor(private val context: Context) : MediaControllerFactory { + + override suspend fun create(controllerListener: MediaController.Listener): MediaController { + val token = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + return MediaController.Builder(context, token) + .setListener(controllerListener) + .buildAsync() + .await() + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt new file mode 100644 index 000000000000..3e46d1f4df3f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.controller + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.nextcloud.client.player.model.state.RepeatMode + +fun Player.indexOfFirst(satisfies: (MediaItem) -> Boolean): Int { + for (index in 0..) { + val oldCurrentMediaItemIndex = currentMediaItemIndex + .takeIf { it >= 0 } + + val newCurrentMediaItemIndex = currentMediaItem + ?.mediaId + ?.let { currentMediaId -> newMediaItems.indexOfFirst { it.mediaId == currentMediaId } } + ?.takeIf { it >= 0 } + + if (oldCurrentMediaItemIndex != null && newCurrentMediaItemIndex != null) { + if (oldCurrentMediaItemIndex < mediaItemCount - 1) { + removeMediaItems(oldCurrentMediaItemIndex + 1, mediaItemCount) + } + if (newCurrentMediaItemIndex < newMediaItems.size - 1) { + val itemsToAdd = newMediaItems.subList(newCurrentMediaItemIndex + 1, newMediaItems.size) + addMediaItems(itemsToAdd) + } + if (oldCurrentMediaItemIndex > 0) { + removeMediaItems(0, oldCurrentMediaItemIndex) + } + if (newCurrentMediaItemIndex > 0) { + val itemsToAdd = newMediaItems.subList(0, newCurrentMediaItemIndex) + addMediaItems(0, itemsToAdd) + } + replaceMediaItem(newCurrentMediaItemIndex, newMediaItems[newCurrentMediaItemIndex]) + } else { + setMediaItems(newMediaItems) + } +} + +fun Player.setRepeatMode(mode: RepeatMode) { + repeatMode = when (mode) { + RepeatMode.SINGLE -> Player.REPEAT_MODE_ONE + RepeatMode.ALL -> Player.REPEAT_MODE_ALL + RepeatMode.OFF -> Player.REPEAT_MODE_OFF + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt new file mode 100644 index 000000000000..3eb015171f67 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.controller + +import androidx.media3.session.MediaController + +interface MediaControllerFactory { + suspend fun create(controllerListener: MediaController.Listener): MediaController +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt new file mode 100644 index 000000000000..a968ffb3ea04 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.datasource + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import com.nextcloud.client.player.model.file.getRemoteFileId +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.StreamMediaFileOperation +import com.owncloud.android.lib.common.OwnCloudClient +import java.io.IOException + +@UnstableApi +class DefaultDataSource( + private val delegate: DataSource, + private val fileDataStorageManager: FileDataStorageManager, + private val ownCloudClient: OwnCloudClient, + private val streamOperationFactory: StreamMediaFileOperationFactory = DefaultStreamMediaFileOperationFactory() +) : DataSource by delegate { + + override fun getResponseHeaders() = delegate.responseHeaders + + override fun open(dataSpec: DataSpec): Long { + val fileId = dataSpec.uri.getRemoteFileId() ?: return delegate.open(dataSpec) + val file = fileDataStorageManager.getFileByLocalId(fileId) + return if (file != null && file.isDown) { + openStoredFile(dataSpec, file) + } else { + openRemoteFile(dataSpec, fileId) + } + } + + private fun openStoredFile(dataSpec: DataSpec, file: OCFile): Long { + val uri = file.storageUri + return delegate.open(dataSpec.buildUpon(uri)) + } + + private fun openRemoteFile(dataSpec: DataSpec, fileId: Long): Long { + val streamMediaFileOperation = streamOperationFactory.create(fileId) + val result = streamMediaFileOperation.execute(ownCloudClient) + return if (result.isSuccess) { + val uri = Uri.parse(result.data[0] as String) + delegate.open(dataSpec.buildUpon(uri)) + } else { + throw IOException("Failed to retrieve streaming uri", result.exception) + } + } + + private fun DataSpec.buildUpon(uri: Uri) = buildUpon().setUri(uri).build() +} + +interface StreamMediaFileOperationFactory { + fun create(fileId: Long): StreamMediaFileOperation +} + +class DefaultStreamMediaFileOperationFactory : StreamMediaFileOperationFactory { + override fun create(fileId: Long) = StreamMediaFileOperation(fileId) +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt new file mode 100644 index 000000000000..bd75383603cb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.datasource + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.HttpDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import javax.inject.Inject + +@UnstableApi +class DefaultDataSourceFactory @Inject constructor( + private val context: Context, + private val cache: Cache, + private val fileDataStorageManager: FileDataStorageManager, + private val clientFactory: ClientFactory, + private val accountManager: UserAccountManager +) : DataSource.Factory { + + override fun createDataSource(): DataSource = CacheDataSource.Factory() + .setUpstreamDataSourceFactory(createUpstreamDataSourceFactory()) + .setCache(cache) + .createDataSource() + + private fun createUpstreamDataSourceFactory() = DataSource.Factory { + DefaultDataSource( + delegate = DefaultDataSource.Factory(context, createHttpDataSourceFactory()).createDataSource(), + fileDataStorageManager = fileDataStorageManager, + ownCloudClient = clientFactory.create(accountManager.user) + ) + } + + private fun createHttpDataSourceFactory(): HttpDataSource.Factory { + val client = clientFactory.createNextcloudClient(accountManager.user).client + return OkHttpDataSource.Factory(client) + .setUserAgent(MainApp.getUserAgent()) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt new file mode 100644 index 000000000000..7b384b2b915e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.resumption + +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.owncloud.android.ui.fragment.SearchType + +data class PlaybackResumptionConfig( + val currentFileId: String, + val folderId: Long, + val fileType: PlaybackFileType, + val searchType: SearchType? +) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt new file mode 100644 index 000000000000..2be370d25ba0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.resumption + +import android.content.Context +import androidx.core.content.edit +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.owncloud.android.ui.fragment.SearchType +import javax.inject.Inject + +class PlaybackResumptionConfigStore @Inject constructor(private val context: Context) { + companion object { + private const val PREFERENCES_FILE_NAME = "playback_resumption_config" + private const val CURRENT_FILE_ID_KEY = "current_file_id" + private const val FOLDER_ID_KEY = "folder_id" + private const val FILE_TYPE_KEY = "file_type" + private const val SEARCH_TYPE_KEY = "search_type" + } + + private val preferences by lazy { + context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + } + + fun loadConfig(): PlaybackResumptionConfig? { + val currentFileId = preferences.getString(CURRENT_FILE_ID_KEY, null) + val folderId = preferences.getLong(FOLDER_ID_KEY, 0L) + val fileType = preferences.getString(FILE_TYPE_KEY, null)?.let(::playbackFileType) + val searchType = preferences.getString(SEARCH_TYPE_KEY, null)?.let(::searchType) + return if (currentFileId != null && folderId != 0L && fileType != null) { + PlaybackResumptionConfig(currentFileId, folderId, fileType, searchType) + } else { + null + } + } + + fun saveConfig(currentFileId: String, folderId: Long, fileType: PlaybackFileType, searchType: SearchType?) { + preferences.edit { + putString(CURRENT_FILE_ID_KEY, currentFileId) + putLong(FOLDER_ID_KEY, folderId) + putString(FILE_TYPE_KEY, fileType.value) + putString(SEARCH_TYPE_KEY, searchType?.name) + } + } + + fun updateCurrentFileId(currentFileId: String) { + preferences.edit { + putString(CURRENT_FILE_ID_KEY, currentFileId) + } + } + + fun clear() { + preferences.edit { + clear() + } + } + + private fun playbackFileType(value: String): PlaybackFileType? = PlaybackFileType.entries.firstOrNull { + it.value == value + } + + private fun searchType(name: String): SearchType? = SearchType.entries.firstOrNull { it.name == name } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt new file mode 100644 index 000000000000..4ac9ed1f2119 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.resumption + +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition +import com.nextcloud.client.player.media3.common.MediaItemFactory +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.PlaybackFilesRepository +import com.nextcloud.client.player.model.file.getPlaybackUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.util.concurrent.CancellationException +import javax.inject.Inject + +@UnstableApi +class PlaybackResumptionLauncher @Inject constructor( + private val playbackResumptionConfigStore: PlaybackResumptionConfigStore, + private val playbackFilesRepository: PlaybackFilesRepository, + private val mediaItemFactory: MediaItemFactory, + private val playbackModel: PlaybackModel +) { + + suspend fun launch(): MediaItemsWithStartPosition = runCatching { + val (currentFileId, folderId, fileType, searchType) = playbackResumptionConfigStore.loadConfig() + ?: throw IllegalStateException("Playback resumption config is null") + val playbackFilesFlow = playbackFilesRepository.observe(folderId, fileType, searchType) + val playbackFiles = playbackFilesFlow.first().list.ifEmpty { + throw IllegalStateException("Playback files are empty") + } + withContext(Dispatchers.Main) { + playbackModel.start() + playbackModel.setFilesFlow(playbackFilesFlow.drop(1)) + } + playbackFiles.toMediaItemsWithStartPosition(currentFileId) + }.getOrElse { + if (it is CancellationException) throw it + val stubPlaybackFile = getStubPlaybackFile() + val stubPlaybackFiles = listOf(stubPlaybackFile) + withContext(Dispatchers.Main) { + playbackModel.start() + } + stubPlaybackFiles.toMediaItemsWithStartPosition(stubPlaybackFile.id) + } + + private fun List.toMediaItemsWithStartPosition(currentFileId: String) = MediaItemsWithStartPosition( + map { mediaItemFactory.create(it) }, + indexOfFirst { it.id == currentFileId }, + 0 + ) + + /** + * Workaround to avoid internal media3 crash + */ + private fun getStubPlaybackFile() = PlaybackFile( + id = "0", + uri = getPlaybackUri(0L).toString(), + name = "", + mimeType = "audio/mpeg", + contentLength = 0L, + lastModified = 0L, + isFavorite = false + ) +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt new file mode 100644 index 000000000000..1b1b7d10ef8b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.resumption + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import javax.inject.Inject + +class PlaybackResumptionPlayerListener @Inject constructor( + private val playbackResumptionConfigStore: PlaybackResumptionConfigStore +) : Player.Listener { + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + mediaItem?.let { playbackResumptionConfigStore.updateCurrentFileId(it.mediaId) } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt new file mode 100644 index 000000000000..bd6ae4d8ccd1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import android.content.Context +import android.os.Bundle +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import com.nextcloud.client.player.media3.common.PlayerFactory +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionPlayerListener +import com.owncloud.android.R +import javax.inject.Inject + +@UnstableApi +class DefaultMediaSessionFactory @Inject constructor( + private val context: Context, + private val playerFactory: PlayerFactory, + private val sessionCallback: MediaSessionCallback, + private val resumptionPlayerListener: PlaybackResumptionPlayerListener, + private val bitmapLoader: MediaSessionBitmapLoader +) : MediaSessionFactory { + + override fun create(): MediaSession { + val player = playerFactory.create() + player.addListener(resumptionPlayerListener) + return MediaSession + .Builder(context, player) + .setBitmapLoader(bitmapLoader) + .setCallback(sessionCallback) + .setCustomLayout(provideCustomLayout()) + .build() + } + + private fun provideCustomLayout(): List = listOf( + CommandButton + .Builder() + .setDisplayName(context.getString(R.string.player_media_controls_close_action_title)) + .setIconResId(R.drawable.player_ic_close) + .setSessionCommand(SessionCommand(MediaSessionCallback.CLOSE_ACTION, Bundle.EMPTY)) + .build() + ) +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt new file mode 100644 index 000000000000..64376ca85714 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.media3.common.MediaItem +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.nextcloud.client.player.ui.PlayerActivity +import javax.inject.Inject + +class MediaSessionActivityFactory @Inject constructor(private val context: Context) { + + fun create(currentMediaItem: MediaItem?): PendingIntent? { + val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null + val fileType = PlaybackFileType.entries + .firstOrNull { currentFile.mimeType.startsWith(it.value, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported file type: ${currentFile.mimeType}") + + val intent = PlayerActivity.createIntent(context, fileType) + + val requestCode = System.currentTimeMillis().toInt() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getActivity(context, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.getActivity(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt new file mode 100644 index 000000000000..385a3796507b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.BitmapLoader +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSourceBitmapLoader +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.ListeningExecutorService +import com.google.common.util.concurrent.MoreExecutors +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.owncloud.android.R +import com.owncloud.android.utils.MimeTypeUtil +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import javax.inject.Inject + +@UnstableApi +class MediaSessionBitmapLoader @Inject constructor( + private val context: Context, + private val thumbnailLoader: ThumbnailLoader +) : BitmapLoader by DataSourceBitmapLoader(context) { + + companion object { + private const val THUMBNAIL_TARGET_SIZE = 160 + private const val LARGE_THUMBNAIL_TARGET_SIZE = 320 + } + + private val thumbnailSize: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + LARGE_THUMBNAIL_TARGET_SIZE + } else { + THUMBNAIL_TARGET_SIZE + } + + private val executorService: ListeningExecutorService by lazy { + MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()) + } + + private var currentBitmapRequest: BitmapRequest? = null + + override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { + val file = metadata.playbackFile + val previousRequest = this.currentBitmapRequest + + if (previousRequest != null && previousRequest.isSameRequest(file, metadata)) { + return previousRequest.bitmapFuture + } + + val bitmapFuture = executorService.submit( + Callable { + getBitmapFromMetadata(metadata, file?.id) ?: run { + file?.let(::getBitmapForFile) ?: getDefaultBitmap(file) + } + } + ) + + this.currentBitmapRequest = BitmapRequest( + file?.id, + metadata.artworkData, + metadata.artworkUri, + bitmapFuture + ) + + return bitmapFuture + } + + private fun getBitmapFromMetadata(metadata: MediaMetadata, fileId: String?): Bitmap? { + val model = metadata.artworkData ?: metadata.artworkUri ?: return null + return runCatching { + thumbnailLoader.load(context, model, fileId, thumbnailSize, thumbnailSize).get() + }.getOrElse { + null + } + } + + private fun getBitmapForFile(file: PlaybackFile): Bitmap? = runCatching { + thumbnailLoader.load(context, file, thumbnailSize, thumbnailSize).get() + }.getOrElse { + null + } + + private fun getDefaultBitmap(file: PlaybackFile?): Bitmap { + val drawable = if (file != null && MimeTypeUtil.isVideo(file.mimeType)) { + ContextCompat.getDrawable(context, R.drawable.player_ic_notification_video) + } else { + ContextCompat.getDrawable(context, R.drawable.player_ic_notification_audio) + } + return drawable?.toBitmap() ?: throw IllegalStateException("Could not decode resource") + } + + private class BitmapRequest( + val mediaId: String?, + val artworkData: ByteArray?, + val artworkUri: Uri?, + val bitmapFuture: ListenableFuture + ) { + + fun isSameRequest(file: PlaybackFile?, metadata: MediaMetadata): Boolean = mediaId == file?.id && + artworkData.contentEquals(metadata.artworkData) && + artworkUri == metadata.artworkUri + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt new file mode 100644 index 000000000000..0bc991a4ce66 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import android.os.Bundle +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionLauncher +import com.nextcloud.client.player.model.PlaybackModel +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.guava.future +import javax.inject.Inject +import javax.inject.Provider + +@UnstableApi +class MediaSessionCallback @Inject constructor( + private val playbackModelProvider: Provider, + private val playbackResumptionLauncherProvider: Provider +) : MediaSession.Callback { + private val playbackModel get() = playbackModelProvider.get() + private val playbackResumptionLauncher get() = playbackResumptionLauncherProvider.get() + + companion object { + const val CLOSE_ACTION = "CLOSE_ACTION" + } + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): ConnectionResult { + val connectionResult = super.onConnect(session, controller) + val sessionCommandsBuilder = connectionResult.availableSessionCommands.buildUpon() + sessionCommandsBuilder.add(SessionCommand(CLOSE_ACTION, Bundle.EMPTY)) + val sessionCommands = sessionCommandsBuilder.build() + return ConnectionResult.accept(sessionCommands, connectionResult.availablePlayerCommands) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + if (customCommand.customAction == CLOSE_ACTION) { + playbackModel.release() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture = GlobalScope.future { playbackResumptionLauncher.launch() } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt new file mode 100644 index 000000000000..5f050f77dfa0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import androidx.media3.session.MediaSession + +interface MediaSessionFactory { + fun create(): MediaSession +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt new file mode 100644 index 000000000000..d94bb3565ceb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.session + +import androidx.media3.session.MediaSession + +interface MediaSessionHolder { + + fun getMediaSession(): MediaSession + + fun release() +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt new file mode 100644 index 000000000000..47ab6377bf31 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model + +import android.content.Context +import android.graphics.Bitmap +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.LazyHeaders +import com.bumptech.glide.signature.ObjectKey +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.player.model.file.PlaybackFile +import com.owncloud.android.MainApp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.concurrent.Future +import javax.inject.Inject +import kotlin.coroutines.resume + +class GlideThumbnailLoader @Inject constructor(clientFactory: ClientFactory, userAccountManager: UserAccountManager) : + ThumbnailLoader { + private val client by lazy { clientFactory.createNextcloudClient(userAccountManager.user) } + + override suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? = + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + runCatching { + val future = load(context, file, width, height) + continuation.invokeOnCancellation { future.cancel(true) } + continuation.resume(future.get()) + }.onFailure { + if (it is CancellationException) throw it + continuation.resume(null) + } + } + } + + override fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future { + val url = createUrl(file, width, height) + return load(context, url, file.id, width, height) + } + + override fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future = Glide + .with(context) + .asBitmap() + .load(model) + .signature(ObjectKey(fileId ?: model.toString())) + .submit(width, height) + + override fun load(imageView: ImageView, model: Any, fileId: String) { + Glide + .with(imageView) + .load(model) + .signature(ObjectKey(fileId)) + .into(imageView) + } + + private fun createUrl(file: PlaybackFile, width: Int, height: Int) = GlideUrl( + "${client.baseUri}/index.php/core/preview?fileId=${file.id}&x=$width&y=$height&a=1&mode=cover&forceIcon=0", + LazyHeaders.Builder() + .addHeader("Authorization", client.credentials) + .addHeader("User-Agent", MainApp.getUserAgent()) + .build() + ) +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt new file mode 100644 index 000000000000..764a4d499906 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model + +import android.view.SurfaceView +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.PlaybackFiles +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.RepeatMode +import kotlinx.coroutines.flow.Flow +import java.util.Optional + +@Suppress("TooManyFunctions") +interface PlaybackModel { + + val state: Optional + + suspend fun start() + + fun setFilesFlow(filesFlow: Flow) + + fun setFiles(files: PlaybackFiles) + + fun release() + + fun setVideoSurfaceView(surfaceView: SurfaceView?) + + fun addListener(listener: Listener) + + fun removeListener(listener: Listener) + + fun play() + + fun pause() + + fun playNext() + + fun playPrevious() + + fun seekToPosition(positionInMilliseconds: Long) + + fun setRepeatMode(repeatMode: RepeatMode) + + fun setShuffle(shuffle: Boolean) + + fun switchToFile(file: PlaybackFile) + + interface Listener { + + fun onPlaybackUpdate(state: PlaybackState) + + fun onPlaybackError(error: Throwable) { + // Default empty implementation + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt new file mode 100644 index 000000000000..c74d1eecf416 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model + +import com.nextcloud.client.player.model.state.PlaybackState + +class PlaybackModelCompositeListener : PlaybackModel.Listener { + private val listeners = mutableListOf() + + fun addListener(listener: PlaybackModel.Listener) { + if (!listeners.contains(listener)) { + listeners.add(listener) + } + } + + fun removeListener(listener: PlaybackModel.Listener?) { + listeners.remove(listener) + } + + override fun onPlaybackUpdate(state: PlaybackState) { + for (i in 0 until listeners.size) { + listeners.getOrNull(i)?.onPlaybackUpdate(state) + } + } + + override fun onPlaybackError(error: Throwable) { + for (i in 0 until listeners.size) { + listeners.getOrNull(i)?.onPlaybackError(error) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt new file mode 100644 index 000000000000..3911747d833b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model + +import android.content.Context +import androidx.core.content.edit +import com.nextcloud.client.player.model.state.RepeatMode +import javax.inject.Inject + +class PlaybackSettings @Inject constructor(context: Context) { + companion object { + private const val PREFERENCES_FILE_NAME = "playback_settings" + private const val REPEAT_MODE_ID_KEY = "repeat_mode_id" + private const val SHUFFLE_KEY = "shuffle" + private val DEFAULT_REPEAT_MODE = RepeatMode.ALL + } + + private val preferences = context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + + val repeatMode: RepeatMode + get() = preferences.getInt(REPEAT_MODE_ID_KEY, -1) + .let { id -> RepeatMode.entries.firstOrNull { it.id == id } } + ?: DEFAULT_REPEAT_MODE + + val isShuffle: Boolean + get() = preferences.getBoolean(SHUFFLE_KEY, false) + + fun setRepeatMode(repeatMode: RepeatMode) { + preferences.edit { + putInt(REPEAT_MODE_ID_KEY, repeatMode.id) + } + } + + fun setShuffle(shuffle: Boolean) { + preferences.edit { + putBoolean(SHUFFLE_KEY, shuffle) + } + } + + fun reset() { + preferences.edit { + clear() + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt new file mode 100644 index 000000000000..5f9a1c9da755 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model + +import android.content.Context +import android.graphics.Bitmap +import android.widget.ImageView +import com.nextcloud.client.player.model.file.PlaybackFile +import java.util.concurrent.Future + +interface ThumbnailLoader { + + suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? + + fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future + + fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future + + fun load(imageView: ImageView, model: Any, fileId: String) +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt b/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt new file mode 100644 index 000000000000..fc1feceb9db3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.error + +import com.nextcloud.client.player.model.state.PlaybackState +import javax.inject.Inject + +class DefaultPlaybackErrorStrategy @Inject constructor() : PlaybackErrorStrategy { + + override fun switchToNextSource(error: Throwable, state: PlaybackState): Boolean { + val currentFile = state.currentItemState?.file + val currentFiles = state.currentFiles + val oneFileInQueue = currentFiles.size == 1 + val endOfQueue = currentFiles.indexOf(currentFile) == currentFiles.lastIndex + return !oneFileInQueue && !endOfQueue + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt b/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt new file mode 100644 index 000000000000..2a8073837a28 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.error + +import com.nextcloud.client.player.model.state.PlaybackState +import java.io.Serializable + +interface PlaybackErrorStrategy : Serializable { + fun switchToNextSource(error: Throwable, state: PlaybackState): Boolean +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt b/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt new file mode 100644 index 000000000000..800fd0cd99b4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.error + +class SourceException(errorCode: Int = 0) : + Exception( + "Source not found. Error code: $errorCode" + ) diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt new file mode 100644 index 000000000000..a3d04362d2bb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import java.io.Serializable + +data class PlaybackFile( + val id: String, + val uri: String, + val name: String, + val mimeType: String, + val contentLength: Long, + val lastModified: Long, + val isFavorite: Boolean +) : Serializable { + fun getNameWithoutExtension(): String = name.substringBeforeLast(".") +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt new file mode 100644 index 000000000000..cb357000604d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.utils.MimeTypeUtil +import java.io.File + +fun OCFile.toPlaybackFile() = PlaybackFile( + id = localId.toString(), + uri = getPlaybackUri().toString(), + name = fileName, + mimeType = mimeType, + contentLength = fileLength, + lastModified = modificationTimestamp, + isFavorite = isFavorite +) + +fun OCShare.toPlaybackFile() = PlaybackFile( + id = fileSource.toString(), + uri = getPlaybackUri().toString(), + name = path?.let { File(it).name } ?: "", + mimeType = getMimeType(), + contentLength = -1L, + lastModified = sharedDate * 1000L, + isFavorite = isFavorite +) + +private fun OCShare.getMimeType(): String = mimetype + ?.takeIf { it.isNotEmpty() } + ?: path?.let { MimeTypeUtil.getMimeTypeFromPath(it) } + ?: "" diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt new file mode 100644 index 000000000000..5dc57c4fe6b5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +enum class PlaybackFileType(val value: String) { + AUDIO("audio"), + VIDEO("video") +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt new file mode 100644 index 000000000000..c6bc9b20c299 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import android.net.Uri +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare + +const val REMOTE_FILE_SCHEME = "remoteFile" + +fun OCFile.getPlaybackUri(): Uri = getPlaybackUri(localId) + +fun OCShare.getPlaybackUri(): Uri = getPlaybackUri(fileSource) + +fun getPlaybackUri(fileId: Long): Uri = Uri.Builder() + .scheme(REMOTE_FILE_SCHEME) + .authority("") + .appendPath(fileId.toString()) + .build() + +fun Uri.getRemoteFileId(): Long? = scheme + ?.takeIf { it == REMOTE_FILE_SCHEME } + ?.let { pathSegments.firstOrNull()?.toLongOrNull() } diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt new file mode 100644 index 000000000000..cc5d1a04752c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +data class PlaybackFiles(val list: List, val comparator: PlaybackFilesComparator) diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt new file mode 100644 index 000000000000..0b977ab02374 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import com.owncloud.android.utils.FileSortOrder +import third_parties.daveKoeller.AlphanumComparator + +sealed interface PlaybackFilesComparator : Comparator { + + object NONE : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = 0 + } + + object FAVORITE : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = AlphanumComparator.compare(a.name, b.name) + } + + object GALLERY : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified } + } + + object SHARED : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified } + } + + data class Folder(val sortType: FileSortOrder.SortType, val isAscending: Boolean) : PlaybackFilesComparator { + private val delegate = createDelegate() + + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = delegate.compare(a, b) + + private fun createDelegate(): Comparator { + val sortTypeComparator: Comparator = when (sortType) { + FileSortOrder.SortType.ALPHABET -> Comparator { a, b -> AlphanumComparator.compare(a.name, b.name) } + FileSortOrder.SortType.SIZE -> compareBy { it.contentLength } + FileSortOrder.SortType.DATE -> compareBy { it.lastModified } + } + return compareByDescending(PlaybackFile::isFavorite) + .thenComparing(if (isAscending) sortTypeComparator else sortTypeComparator.reversed()) + } + } +} + +fun FileSortOrder.toPlaybackFilesComparator(): PlaybackFilesComparator = + PlaybackFilesComparator.Folder(getType(), isAscending) diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt new file mode 100644 index 000000000000..f000b9252b65 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt @@ -0,0 +1,160 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import com.nextcloud.client.player.util.observeContentChanges +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.utils.FileSortOrder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class PlaybackFilesRepository( + private val storageManager: FileDataStorageManager, + private val preferences: AppPreferences, + private val dispatcher: CoroutineDispatcher, + private val contentObserver: (uri: Uri, notifyForDescendants: Boolean) -> Flow +) { + + @Inject + constructor(context: Context, storageManager: FileDataStorageManager, preferences: AppPreferences) : this( + storageManager, + preferences, + Dispatchers.IO, + context.contentResolver::observeContentChanges + ) + + companion object { + private const val FETCH_DATA_DEBOUNCE_MS = 250L + } + + fun observe(folderId: Long, fileType: PlaybackFileType, searchType: SearchType?): Flow = + when (searchType) { + SearchType.FAVORITE_SEARCH -> observeFavoritePlaybackFiles(fileType) + SearchType.GALLERY_SEARCH -> observeGalleryPlaybackFiles(fileType) + SearchType.SHARED_FILTER -> observeSharedPlaybackFiles(fileType) + else -> observeFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice()) + } + + suspend fun get(folderId: Long, fileType: PlaybackFileType, searchType: SearchType?): PlaybackFiles = + when (searchType) { + SearchType.FAVORITE_SEARCH -> getFavoritePlaybackFiles(fileType) + SearchType.GALLERY_SEARCH -> getGalleryPlaybackFiles(fileType) + SearchType.SHARED_FILTER -> getSharedPlaybackFiles(fileType) + else -> getFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice()) + } + + private fun observeFavoritePlaybackFiles(fileType: PlaybackFileType): Flow { + val uri = ProviderTableMeta.CONTENT_URI + return observeData(uri, true) { + getFavoritePlaybackFiles(fileType) + } + } + + private suspend fun getFavoritePlaybackFiles(fileType: PlaybackFileType): PlaybackFiles = withContext(dispatcher) { + storageManager.favoriteFiles + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(PlaybackFilesComparator.FAVORITE) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.FAVORITE) } + } + + private fun observeGalleryPlaybackFiles(fileType: PlaybackFileType): Flow { + val uri = ProviderTableMeta.CONTENT_URI + return observeData(uri, true) { + getGalleryPlaybackFiles(fileType) + } + } + + private suspend fun getGalleryPlaybackFiles(fileType: PlaybackFileType): PlaybackFiles = withContext(dispatcher) { + storageManager.allGalleryItems + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(PlaybackFilesComparator.GALLERY) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.GALLERY) } + } + + private fun observeSharedPlaybackFiles(fileType: PlaybackFileType): Flow { + val uri = ProviderTableMeta.CONTENT_URI_SHARE + return observeData(uri, false) { + getSharedPlaybackFiles(fileType) + } + } + + private suspend fun getSharedPlaybackFiles(fileType: PlaybackFileType): PlaybackFiles = withContext(dispatcher) { + storageManager.shares + .asSequence() + .distinctBy { it.fileSource } + .map { it.toPlaybackFile() } + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .sortedWith(PlaybackFilesComparator.SHARED) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.SHARED) } + } + + private fun observeFolderPlaybackFiles( + folderId: Long, + fileType: PlaybackFileType, + onDeviceOnly: Boolean + ): Flow { + val uri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, folderId) + val sortOrderFlow = flow { + emit(getFolderSortOrder(folderId)) + } + return sortOrderFlow.flatMapConcat { sortOrder -> + val comparator = sortOrder.toPlaybackFilesComparator() + observeData(uri, false) { + getFolderPlaybackFiles(folderId, fileType, onDeviceOnly, comparator) + } + } + } + + private suspend fun getFolderPlaybackFiles( + folderId: Long, + fileType: PlaybackFileType, + onDeviceOnly: Boolean, + comparator: PlaybackFilesComparator? = null + ): PlaybackFiles = withContext(dispatcher) { + val folder = storageManager.getFileById(folderId) ?: throw IllegalStateException("Folder not found") + val comparator = comparator ?: preferences.getSortOrderByFolder(folder).toPlaybackFilesComparator() + storageManager.getFolderContent(folder, onDeviceOnly) + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(comparator) + .let { PlaybackFiles(it.toList(), comparator) } + } + + private suspend fun getFolderSortOrder(folderId: Long): FileSortOrder = withContext(dispatcher) { + val folder = storageManager.getFileById(folderId) ?: throw IllegalStateException("Folder not found") + preferences.getSortOrderByFolder(folder) + } + + private fun observeData(uri: Uri, notifyForDescendants: Boolean, fetchData: suspend () -> T): Flow = + contentObserver(uri, notifyForDescendants) + .debounce(FETCH_DATA_DEBOUNCE_MS) // Debounce to avoid too frequent data fetching for batch updates + .map { fetchData() } + .onStart { emit(fetchData()) } + .distinctUntilChanged() +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt new file mode 100644 index 000000000000..6653376c7e94 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +data class PlaybackItemMetadata( + val title: CharSequence, + val artist: CharSequence? = null, + val album: CharSequence? = null, + val genre: CharSequence? = null, + val year: Int? = null, + val description: CharSequence? = null, + val artworkData: ByteArray? = null, + val artworkUri: CharSequence? = null +) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt new file mode 100644 index 000000000000..6d60cd0dc576 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.state + +import com.nextcloud.client.player.model.file.PlaybackFile +import java.io.Serializable + +data class PlaybackItemState( + val file: PlaybackFile, + val playerState: PlayerState, + val metadata: PlaybackItemMetadata?, + val videoSize: VideoSize?, + val currentTimeInMilliseconds: Long, + val maxTimeInMilliseconds: Long +) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt new file mode 100644 index 000000000000..93f17811b5f4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.state + +import com.nextcloud.client.player.model.file.PlaybackFile +import java.io.Serializable + +data class PlaybackState( + val currentFiles: List, + val currentItemState: PlaybackItemState?, + val repeatMode: RepeatMode, + val shuffle: Boolean +) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt new file mode 100644 index 000000000000..fd5743cc7adc --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +enum class PlayerState : Serializable { + IDLE, + PLAYING, + PAUSED, + COMPLETED, + NONE +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt new file mode 100644 index 000000000000..ac07ac9bc28f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +enum class RepeatMode(val id: Int) : Serializable { + OFF(0), + SINGLE(1), + ALL(2) +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt b/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt new file mode 100644 index 000000000000..4d2a19490fbc --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +data class VideoSize(val width: Int, val height: Int) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt new file mode 100644 index 000000000000..5dd9c38ffd2f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -0,0 +1,241 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui + +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Rect +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.util.Rational +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.core.view.ViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.nextcloud.client.player.ui.audio.AudioPlayerView +import com.nextcloud.client.player.ui.video.VideoPlayerView +import com.nextcloud.client.player.util.isPictureInPictureAllowed +import com.nextcloud.ui.fileactions.FileAction +import com.nextcloud.ui.fileactions.FileActionsBottomSheet +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +private const val PIP_ASPECT_RATIO_WIDTH = 16 +private const val PIP_ASPECT_RATIO_HEIGHT = 9 + +class PlayerActivity : + FileActivity(), + Injectable { + + companion object { + private const val PLAYBACK_FILE_TYPE: String = "PLAYBACK_FILE_TYPE" + + fun createIntent(context: Context, playbackFileType: PlaybackFileType): Intent = + Intent(context, PlayerActivity::class.java).apply { + putExtra(PLAYBACK_FILE_TYPE, playbackFileType) + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + } + } + + @Inject + lateinit var playbackModel: PlaybackModel + + @Inject + lateinit var viewModelFactory: PlayerViewModel.Factory + + private val viewModel by viewModels { viewModelFactory } + + private lateinit var playbackFileType: PlaybackFileType + + private lateinit var playerView: PlayerView + + private val pipAspectRatio = Rational(PIP_ASPECT_RATIO_WIDTH, PIP_ASPECT_RATIO_HEIGHT) + + private var onBackPressedCallback: OnBackPressedCallback? = null + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, windowInsets -> windowInsets } + + playbackFileType = intent.getPlaybackFileType() + createPlayerView() + + viewModel.eventFlow + .flowWithLifecycle(lifecycle) + .onEach { handleEvent(it) } + .launchIn(lifecycleScope) + + if (isPictureInPictureAllowed()) { + val isVideoPlayback = playbackFileType == PlaybackFileType.VIDEO + onBackPressedCallback = onBackPressedDispatcher.addCallback(this, enabled = isVideoPlayback) { + switchToPictureInPictureMode() + } + } + + volumeControlStream = AudioManager.STREAM_MUSIC + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + playbackFileType = intent.getPlaybackFileType() + recreatePlayerView() + onBackPressedCallback?.isEnabled = canUsePictureInPictureMode() + } + + private fun createPlayerView() { + playerView = when (playbackFileType) { + PlaybackFileType.AUDIO -> AudioPlayerView(this) + PlaybackFileType.VIDEO -> VideoPlayerView(this) + } + val moreButton = playerView.findViewById(R.id.more) + moreButton.setOnClickListener { viewModel.onMoreButtonClick() } + setContentView(playerView) + } + + private fun recreatePlayerView() { + playerView.onStop() + createPlayerView() + playerView.onStart() + } + + @Suppress("DEPRECATION") + private fun Intent.getPlaybackFileType(): PlaybackFileType { + val playbackFileType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializableExtra(PLAYBACK_FILE_TYPE, PlaybackFileType::class.java) + } else { + getSerializableExtra(PLAYBACK_FILE_TYPE) as PlaybackFileType? + } + return playbackFileType ?: throw IllegalStateException("Playback file type was not defined") + } + + override fun onStart() { + super.onStart() + playerView.onStart() + } + + override fun onStop() { + super.onStop() + playerView.onStop() + } + + override fun onDestroy() { + super.onDestroy() + if (isFinishing && playbackFileType == PlaybackFileType.VIDEO) { + playbackModel.release() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + recreatePlayerView() + if (isInPictureInPictureMode) { + (playerView as? VideoPlayerView)?.hideControls() + } else { + (playerView as? VideoPlayerView)?.showControls() + } + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (canUsePictureInPictureMode()) { + switchToPictureInPictureMode() + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (!isInPictureInPictureMode && lifecycle.currentState == Lifecycle.State.CREATED) { + finish() // Finish the activity if the user closes the PIP window + } + } + + private fun canUsePictureInPictureMode(): Boolean = + playbackFileType == PlaybackFileType.VIDEO && isPictureInPictureAllowed() + + private fun switchToPictureInPictureMode() { + val params = createPictureInPictureParams() + enterPictureInPictureMode(params) + } + + private fun createPictureInPictureParams(): PictureInPictureParams = PictureInPictureParams.Builder().let { + it.setAspectRatio(pipAspectRatio) + getSourceRectHint().let(it::setSourceRectHint) + it.build() + } + + private fun getSourceRectHint(): Rect? { + val containerRect = Rect() + playerView.getGlobalVisibleRect(containerRect) + val sourceHeightHint = (containerRect.width() / pipAspectRatio.toFloat()).toInt() + return Rect( + containerRect.left, + containerRect.top + (containerRect.height() - sourceHeightHint) / 2, + containerRect.right, + containerRect.top + (containerRect.height() + sourceHeightHint) / 2 + ) + } + + private fun handleEvent(event: PlayerScreenEvent) { + when (event) { + is PlayerScreenEvent.ShowFileActions -> showFileActions(event.file, event.actionIds) + is PlayerScreenEvent.ShowFileDetails -> showFileDetails(event.file) + is PlayerScreenEvent.ShowFileExportStartedMessage -> showFileExportStartedMessage() + is PlayerScreenEvent.ShowShareFileDialog -> fileOperationsHelper.sendShareFile(event.file) + is PlayerScreenEvent.ShowRemoveFileDialog -> showRemoveFileDialog(event.file) + is PlayerScreenEvent.LaunchOpenFileIntent -> fileOperationsHelper.openFile(event.file) + is PlayerScreenEvent.LaunchStreamFileIntent -> fileOperationsHelper.streamMediaFile(event.file) + } + } + + private fun showFileActions(file: OCFile, actionIds: List) { + val actionsToHide = FileAction.entries.map(FileAction::id).filter { it !in actionIds } + FileActionsBottomSheet.newInstance(file, false, actionsToHide) + .setResultListener(supportFragmentManager, this) { viewModel.onFileActionChosen(file, it) } + .show(supportFragmentManager, "actions") + } + + private fun showFileDetails(file: OCFile) { + val intent = Intent(this, FileDisplayActivity::class.java).apply { + action = FileDisplayActivity.ACTION_DETAILS + putExtra(EXTRA_FILE, file) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + startActivity(intent) + finish() + } + + private fun showFileExportStartedMessage() { + val message = resources.getQuantityString(R.plurals.export_start, 1, 1) + DisplayUtils.showSnackMessage(playerView, message) + } + + private fun showRemoveFileDialog(file: OCFile) { + RemoveFilesDialogFragment.newInstance(file) + .show(supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt new file mode 100644 index 000000000000..bb237142dc4f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.logger.Logger +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.nextcloud.client.player.model.file.PlaybackFiles +import com.nextcloud.client.player.model.file.PlaybackFilesComparator +import com.nextcloud.client.player.model.file.PlaybackFilesRepository +import com.nextcloud.client.player.model.file.toPlaybackFile +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.SearchType +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.util.concurrent.CancellationException +import javax.inject.Inject + +class PlayerLauncher @Inject constructor( + private val playbackResumptionConfigStore: PlaybackResumptionConfigStore, + private val playbackFilesRepository: PlaybackFilesRepository, + private val playbackModel: PlaybackModel, + private val logger: Logger +) { + private var currentLaunchJob: Job? = null + + fun launch(activity: AppCompatActivity, file: OCFile, searchType: SearchType?) { + currentLaunchJob?.cancel() + currentLaunchJob = activity.lifecycleScope.launch { + runCatching { + val fileType = file.getPlaybackFileType() + playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, searchType) + + val currentPlaybackFile = file.toPlaybackFile() + + playbackModel.start() + playbackModel.setFiles(PlaybackFiles(listOf(currentPlaybackFile), PlaybackFilesComparator.NONE)) + playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, searchType)) + playbackModel.play() + + val intent = PlayerActivity.createIntent(activity, fileType) + activity.startActivity(intent) + }.onFailure { + if (it is CancellationException) throw it + logger.e(PlayerLauncher::class.java.simpleName, "Error launching player", it) + } + } + } + + private fun OCFile.getPlaybackFileType(): PlaybackFileType = PlaybackFileType.entries + .firstOrNull { mimeType.startsWith(it.value, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported file type: $mimeType") +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt new file mode 100644 index 000000000000..04fbe09a9da9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.AttrRes +import com.google.android.material.progressindicator.LinearProgressIndicator +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.owncloud.android.datamodel.OCFile +import dagger.android.HasAndroidInjector +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class PlayerProgressIndicator @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0 +) : LinearProgressIndicator(context, attrs, defStyleAttr), + PlaybackModel.Listener { + + @Inject + lateinit var playbackModel: PlaybackModel + + private var playbackFile: PlaybackFile? = null + + init { + indicatorTrackGapSize = 0 + trackStopIndicatorSize = 0 + if (!isInEditMode) { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!isInEditMode) { + renderCurrentState() + playbackModel.addListener(this) + } + } + + override fun onDetachedFromWindow() { + if (!isInEditMode) { + playbackModel.removeListener(this) + } + visibility = GONE + super.onDetachedFromWindow() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + val itemState = state.currentItemState + render(itemState) + } + + fun setFile(file: OCFile) { + playbackFile = file.toPlaybackFile() + renderCurrentState() + } + + private fun renderCurrentState() { + val itemState = playbackModel.state.getOrNull()?.currentItemState + render(itemState) + } + + private fun render(itemState: PlaybackItemState?) { + if (itemState != null && + itemState.playerState != PlayerState.COMPLETED && + itemState.file.id == playbackFile?.id + ) { + max = itemState.maxTimeInMilliseconds.toInt() + progress = itemState.currentTimeInMilliseconds.toInt() + visibility = VISIBLE + } else { + visibility = GONE + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt new file mode 100644 index 000000000000..07a25bafb0cb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui + +import com.owncloud.android.datamodel.OCFile + +sealed interface PlayerScreenEvent { + + data class ShowFileActions(val file: OCFile, val actionIds: List) : PlayerScreenEvent + + data class ShowFileDetails(val file: OCFile) : PlayerScreenEvent + + data object ShowFileExportStartedMessage : PlayerScreenEvent + + data class ShowShareFileDialog(val file: OCFile) : PlayerScreenEvent + + data class ShowRemoveFileDialog(val file: OCFile) : PlayerScreenEvent + + data class LaunchOpenFileIntent(val file: OCFile) : PlayerScreenEvent + + data class LaunchStreamFileIntent(val file: OCFile) : PlayerScreenEvent +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt new file mode 100644 index 000000000000..7aee591f7bce --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.CallSuper +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.error.SourceException +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.ui.control.PlayerControlView +import com.nextcloud.client.player.ui.pager.PlayerPager +import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory +import com.nextcloud.client.player.ui.pager.PlayerPagerMode +import com.nextcloud.client.player.util.WindowWrapper +import com.owncloud.android.R +import com.owncloud.android.utils.DisplayUtils +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +abstract class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), + PlaybackModel.Listener { + + @Inject + lateinit var playbackModel: PlaybackModel + + @get:LayoutRes + protected abstract val layoutRes: Int + + protected abstract val fragmentFactory: PlayerPagerFragmentFactory + + protected val activity: AppCompatActivity by lazy { context as AppCompatActivity } + protected val windowWrapper: WindowWrapper by lazy { WindowWrapper(activity.window) } + + protected val topBar: View by lazy { findViewById(R.id.topBar) } + protected val titleTextView: TextView by lazy { findViewById(R.id.title) } + protected val playerPager: PlayerPager by lazy { findViewById(R.id.playerPager) } + protected val playerControlView: PlayerControlView by lazy { findViewById(R.id.playerControlView) } + + init { + inflate(context, layoutRes, this) + if (!isInEditMode) { + inject(context) + playerPager.initialize(activity.supportFragmentManager, PlayerPagerMode.INFINITE, fragmentFactory) + playerPager.setPlayerPagerListener { playbackModel.switchToFile(it) } + findViewById(R.id.back).setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() } + } + } + + protected abstract fun inject(context: Context) + + @CallSuper + open fun onStart() { + val state = playbackModel.state.getOrNull() + if (state == null) { + activity.finish() + return + } + + render(state) + playbackModel.addListener(this) + playerControlView.onStart() + } + + @CallSuper + open fun onStop() { + playbackModel.removeListener(this) + playerControlView.onStop() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + override fun onPlaybackError(error: Throwable) { + if (error is SourceException) { + DisplayUtils.showSnackMessage(this, R.string.player_error_source_not_found) + } else { + DisplayUtils.showSnackMessage(this, R.string.common_error_unknown) + } + } + + private fun render(state: PlaybackState) { + val currentFiles = state.currentFiles + if (state.currentFiles.isEmpty()) { + activity.finish() + return + } + + if (playerPager.getItems() != currentFiles) { + playerPager.setItems(currentFiles) + } + + if (state.currentItemState != null) { + val file = state.currentItemState.file + titleTextView.text = file.getNameWithoutExtension() + playerPager.setCurrentItem(file) + } else { + titleTextView.text = "" + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt new file mode 100644 index 000000000000..49f63a084daf --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt @@ -0,0 +1,114 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui + +import androidx.core.text.isDigitsOnly +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.logger.Logger +import com.nextcloud.client.player.model.PlaybackModel +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Provider +import kotlin.coroutines.cancellation.CancellationException +import kotlin.jvm.optionals.getOrNull + +class PlayerViewModel @Inject constructor( + private val playbackModel: PlaybackModel, + private val storageManager: FileDataStorageManager, + private val userAccountManager: UserAccountManager, + private val backgroundJobManager: BackgroundJobManager, + private val logger: Logger +) : ViewModel() { + + private val eventChannel = Channel(Channel.BUFFERED) + val eventFlow: Flow = eventChannel.receiveAsFlow() + + fun onMoreButtonClick() { + viewModelScope.launch { + val file = getCurrentOCFile() ?: return@launch + val actionIds = listOf( + R.id.action_see_details, + R.id.action_download_file, + R.id.action_export_file, + R.id.action_send_share_file, + R.id.action_remove_file, + R.id.action_open_file_with, + R.id.action_stream_media + ) + eventChannel.trySend(PlayerScreenEvent.ShowFileActions(file, actionIds)) + } + } + + fun onFileActionChosen(file: OCFile, actionId: Int) { + when (actionId) { + R.id.action_see_details -> eventChannel.trySend(PlayerScreenEvent.ShowFileDetails(file)) + R.id.action_download_file -> startFileDownloading(file) + R.id.action_export_file -> startFileExport(file) + R.id.action_send_share_file -> eventChannel.trySend(PlayerScreenEvent.ShowShareFileDialog(file)) + R.id.action_remove_file -> eventChannel.trySend(PlayerScreenEvent.ShowRemoveFileDialog(file)) + R.id.action_open_file_with -> onOpenFileWithClick(file) + R.id.action_stream_media -> onStreamFileClick(file) + } + } + + private suspend fun getCurrentOCFile(): OCFile? { + val currentFileId = playbackModel.state.getOrNull()?.currentItemState?.file?.id + return currentFileId + ?.takeIf { it.isDigitsOnly() } + ?.let { getOCFile(it.toLong()) } + } + + private suspend fun getOCFile(localId: Long): OCFile? = withContext(Dispatchers.IO) { + runCatching { + storageManager.getFileByLocalId(localId) + }.getOrElse { + if (it is CancellationException) throw it + logger.e(PlayerViewModel::class.java.simpleName, "Failed to get file by localId: $localId", it) + null + } + } + + private fun startFileDownloading(file: OCFile) { + val user = userAccountManager.user + FileDownloadHelper.instance().downloadFileIfNotStartedBefore(user, file) + } + + private fun startFileExport(file: OCFile) { + backgroundJobManager.startImmediateFilesExportJob(listOf(file)) + eventChannel.trySend(PlayerScreenEvent.ShowFileExportStartedMessage) + } + + private fun onOpenFileWithClick(file: OCFile) { + playbackModel.pause() + eventChannel.trySend(PlayerScreenEvent.LaunchOpenFileIntent(file)) + } + + private fun onStreamFileClick(file: OCFile) { + playbackModel.pause() + eventChannel.trySend(PlayerScreenEvent.LaunchStreamFileIntent(file)) + } + + class Factory @Inject constructor(private val viewModelProvider: Provider) : + ViewModelProvider.Factory { + + override fun create(modelClass: Class): T = viewModelProvider.get() as T + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt new file mode 100644 index 000000000000..359f09afe120 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -0,0 +1,124 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.audio + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemMetadata +import com.nextcloud.client.player.model.state.PlaybackState +import com.owncloud.android.R +import com.owncloud.android.databinding.PlayerAudioFileFragmentBinding +import com.owncloud.android.utils.DisplayUtils +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import javax.inject.Inject + +open class AudioFileFragment : + Fragment(), + PlaybackModel.Listener { + + companion object { + private const val ARGUMENT_FILE = "ARGUMENT_FILE" + + fun createInstance(file: PlaybackFile) = AudioFileFragment().apply { + arguments = bundleOf(ARGUMENT_FILE to file) + } + } + + @Inject + lateinit var playbackModel: PlaybackModel + + @Inject + lateinit var thumbnailLoader: ThumbnailLoader + + private lateinit var binding: PlayerAudioFileFragmentBinding + private lateinit var loadFileThumbnailJob: Job + private var isFileThumbnailLoaded = false + private var metadata: PlaybackItemMetadata? = null + private val file by lazy { arguments?.getSerializable(ARGUMENT_FILE) as PlaybackFile } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AndroidSupportInjection.inject(this) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = PlayerAudioFileFragmentBinding.inflate(inflater, container, false) + binding.title.isSelected = true + binding.title.text = file.getNameWithoutExtension() + binding.fileDetails.text = file.getDetailsText() + loadFileThumbnailJob = loadFileThumbnail() + return binding.getRoot() + } + + override fun onStart() { + super.onStart() + playbackModel.state.ifPresent(::onPlaybackUpdate) + playbackModel.addListener(this) + } + + override fun onStop() { + playbackModel.removeListener(this) + super.onStop() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + state.currentItemState?.let { + if (it.file.id == file.id && it.metadata != null && it.metadata != metadata) { + onMetadataUpdate(it.metadata) + } + } + } + + private fun onMetadataUpdate(metadata: PlaybackItemMetadata) { + this.metadata = metadata + if (!isFileThumbnailLoaded && (metadata.artworkData != null || metadata.artworkUri != null)) { + loadFileThumbnailJob.takeIf { it.isActive }?.cancel() + loadMetadataArtwork(metadata) + } + binding.title.text = if (metadata.artist.isNullOrEmpty()) { + metadata.title + } else { + "${metadata.artist} • ${metadata.title}" + } + } + + private fun loadFileThumbnail(): Job = viewLifecycleOwner.lifecycleScope.launch { + val thumbnailSize = resources.getDimension(R.dimen.player_album_cover_size).toInt() + val thumbnail = thumbnailLoader.await(requireContext(), file, thumbnailSize, thumbnailSize) + if (thumbnail != null) { + binding.albumCover.setImageBitmap(thumbnail) + isFileThumbnailLoaded = true + } + } + + private fun loadMetadataArtwork(metadata: PlaybackItemMetadata) { + val source = metadata.artworkData ?: metadata.artworkUri ?: return + thumbnailLoader.load(binding.albumCover, source, file.id) + } + + private fun PlaybackFile.getDetailsText(): String { + val size = if (contentLength > 0) DisplayUtils.bytesToHumanReadable(contentLength) else "" + val date = if (lastModified > 0) getLastModifiedText(lastModified) else "" + return if (size.isNotEmpty() && date.isNotEmpty()) "$size, $date" else size + date + } + + private fun getLastModifiedText(lastModified: Long): String { + val relativeTimestamp = DisplayUtils.getRelativeTimestamp(context, lastModified) + return getString(R.string.player_last_modified, relativeTimestamp) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt new file mode 100644 index 000000000000..94d6965431ec --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.audio + +import androidx.fragment.app.Fragment +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory + +class AudioFileFragmentFactory : PlayerPagerFragmentFactory { + + override fun create(item: PlaybackFile): Fragment = AudioFileFragment.createInstance(item) +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt new file mode 100644 index 000000000000..e13b421eb1cd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.audio + +import android.content.Context +import android.view.WindowInsets +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type +import com.nextcloud.client.player.ui.PlayerView +import com.owncloud.android.R +import dagger.android.HasAndroidInjector + +class AudioPlayerView(context: Context) : PlayerView(context) { + + override val layoutRes get() = R.layout.player_audio_view + + override val fragmentFactory get() = AudioFileFragmentFactory() + + override fun inject(context: Context) { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + } + + override fun onStart() { + super.onStart() + windowWrapper.showSystemBars() + } + + override fun onApplyWindowInsets(windowInsets: WindowInsets): WindowInsets? { + val windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets) + val insets = windowInsetsCompat.getInsets(Type.systemBars() or Type.displayCutout()) + + topBar.setPadding(insets.left, insets.top, insets.right, 0) + playerPager.setPadding(insets.left, 0, insets.right, 0) + playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom) + + windowWrapper.setupStatusBar(R.color.player_background_color, false) + windowWrapper.setupNavigationBar(R.color.player_background_color, true) + + return WindowInsetsCompat.CONSUMED.toWindowInsets() + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt new file mode 100644 index 000000000000..6002bef005f7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.control + +import android.os.Handler +import android.os.Looper +import android.view.View +import java.util.Optional + +abstract class MultipleClickListener : View.OnClickListener { + + companion object { + private const val TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS = 250L + } + + private val handler = Handler(Looper.getMainLooper()) + private var clicksCount = Optional.empty() + + protected abstract fun onSingleClick(view: View?) + + protected abstract fun onDoubleClick(view: View?) + + override fun onClick(view: View?) { + val interactionIsBegan = clicksCount.isPresent + + if (interactionIsBegan) { + clicksCount = Optional.of(clicksCount.get() + 1) + } else { + clicksCount = Optional.of(1) + + handler.postDelayed({ + val count = clicksCount.get() + clicksCount = Optional.empty() + callSubscriber(view, count) + }, TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS) + } + } + + private fun callSubscriber(view: View?, clicksCount: Int) { + if (clicksCount == 1) { + onSingleClick(view) + } else { + onDoubleClick(view) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt new file mode 100644 index 000000000000..11dc156d64e8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -0,0 +1,230 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.control + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.util.setTint +import com.owncloud.android.R +import com.owncloud.android.databinding.PlayerControlViewBinding +import dagger.android.HasAndroidInjector +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +private const val INDETERMINATE_TIME = "--:--" +private const val TAG_CLICK_COMMAND_PLAY = "TAG_CLICK_COMMAND_PLAY" +private const val TAG_CLICK_COMMAND_PAUSE = "TAG_CLICK_COMMAND_PAUSE" +private const val TAG_CLICK_COMMAND_REPEAT = "TAG_CLICK_COMMAND_REPEAT" +private const val TAG_CLICK_COMMAND_DO_NOT_REPEAT = "TAG_CLICK_COMMAND_DO_NOT_REPEAT" +private const val TAG_CLICK_COMMAND_SHUFFLE = "TAG_CLICK_COMMAND_SHUFFLE" +private const val TAG_CLICK_COMMAND_DO_NOT_SHUFFLE = "TAG_CLICK_COMMAND_DO_NOT_SHUFFLE" +private const val TAG_CLICK_COMMAND_UNKNOWN = "TAG_CLICK_COMMAND_UNKNOWN" + +private const val PROGRESS_CHANGE_DEBOUNCE_MS = 200L +private const val DEFAULT_MIN_PROGRESS = 0 +private const val DEFAULT_MAX_PROGRESS = 100 +private const val MILLISECONDS_IN_SECOND = 1000 +private const val MILLISECONDS_IN_HOUR = 3_600_000 +private const val SECONDS_IN_MINUTE = 60 +private const val MINUTES_IN_HOUR = 60 + +class PlayerControlView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + injectedPlaybackModel: PlaybackModel? = null +) : LinearLayout(context, attrs, defStyleAttr), + PlaybackModel.Listener { + + @Inject + lateinit var playbackModel: PlaybackModel + + private val seekBarProgressChangeFlow = MutableSharedFlow(extraBufferCapacity = 1) + private var viewScope: CoroutineScope? = null + + val binding = PlayerControlViewBinding.inflate(LayoutInflater.from(context), this, true) + + init { + if (!isInEditMode) { + if (injectedPlaybackModel == null) { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + } else { + playbackModel = injectedPlaybackModel + } + setDefaultTags() + setListeners() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!isInEditMode) { + viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + collectSeekBarChanges() + } + } + + override fun onDetachedFromWindow() { + if (!isInEditMode) { + viewScope?.cancel() + viewScope = null + } + super.onDetachedFromWindow() + } + + fun onStart() { + playbackModel.state.ifPresent(::render) + playbackModel.addListener(this) + } + + fun onStop() { + playbackModel.removeListener(this) + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + private fun setDefaultTags() { + binding.ivPlayPause.tag = TAG_CLICK_COMMAND_UNKNOWN + binding.ivRandom.tag = TAG_CLICK_COMMAND_UNKNOWN + binding.ivRepeat.tag = TAG_CLICK_COMMAND_UNKNOWN + } + + private fun setListeners() { + binding.ivPlayPause.setOnClickListener { + when (binding.ivPlayPause.tag) { + TAG_CLICK_COMMAND_PLAY -> playbackModel.play() + TAG_CLICK_COMMAND_PAUSE -> playbackModel.pause() + } + } + + binding.ivRepeat.setOnClickListener { + when (binding.ivRepeat.tag) { + TAG_CLICK_COMMAND_REPEAT -> playbackModel.setRepeatMode(RepeatMode.SINGLE) + TAG_CLICK_COMMAND_DO_NOT_REPEAT -> playbackModel.setRepeatMode(RepeatMode.ALL) + } + } + + binding.ivRandom.setOnClickListener { + playbackModel.setShuffle(binding.ivRandom.tag == TAG_CLICK_COMMAND_SHUFFLE) + } + + binding.ivNext.setOnClickListener { playbackModel.playNext() } + + binding.ivPrevious.setOnClickListener(object : MultipleClickListener() { + override fun onSingleClick(view: View?) { + val state = playbackModel.state.getOrNull()?.currentItemState ?: return + if (state.playerState == PlayerState.PAUSED || state.playerState == PlayerState.PLAYING) { + playbackModel.seekToPosition(0L) + } else { + playbackModel.playPrevious() + } + } + + override fun onDoubleClick(view: View?) { + playbackModel.playPrevious() + } + }) + + binding.progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + seekBarProgressChangeFlow.tryEmit(progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit + }) + } + + @OptIn(FlowPreview::class) + private fun collectSeekBarChanges() { + val viewScope = viewScope ?: return + val lifecycleOwner = (context as? LifecycleOwner) ?: return + seekBarProgressChangeFlow + .debounce(PROGRESS_CHANGE_DEBOUNCE_MS) + .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .onEach { playbackModel.seekToPosition(it.toLong()) } + .launchIn(viewScope) + } + + private fun render(playbackState: PlaybackState) { + renderRepeatButton(playbackState.repeatMode == RepeatMode.SINGLE) + renderShuffleButton(playbackState.shuffle) + renderPlayPauseButton(playbackState.currentItemState?.playerState == PlayerState.PLAYING) + renderNextPreviousButtons(playbackState) + renderProgressBar(playbackState.currentItemState) + } + + private fun renderRepeatButton(repeatSingle: Boolean) { + binding.ivRepeat.setTint(if (repeatSingle) R.color.player_accent_color else R.color.player_default_icon_color) + binding.ivRepeat.tag = if (repeatSingle) TAG_CLICK_COMMAND_DO_NOT_REPEAT else TAG_CLICK_COMMAND_REPEAT + } + + private fun renderShuffleButton(shuffle: Boolean) { + binding.ivRandom.setTint(if (shuffle) R.color.player_accent_color else R.color.player_default_icon_color) + binding.ivRandom.tag = if (shuffle) TAG_CLICK_COMMAND_DO_NOT_SHUFFLE else TAG_CLICK_COMMAND_SHUFFLE + } + + private fun renderPlayPauseButton(isPlaying: Boolean) { + binding.ivPlayPause.setImageResource(if (isPlaying) R.drawable.player_ic_pause else R.drawable.player_ic_play) + binding.ivPlayPause.tag = if (isPlaying) TAG_CLICK_COMMAND_PAUSE else TAG_CLICK_COMMAND_PLAY + } + + private fun renderNextPreviousButtons(playbackState: PlaybackState) { + binding.ivNext.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.size > 1) + binding.ivPrevious.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.isNotEmpty()) + } + + private fun renderProgressBar(playbackItemState: PlaybackItemState?) { + val enabled = playbackItemState != null && playbackItemState.maxTimeInMilliseconds > DEFAULT_MIN_PROGRESS + val max = if (enabled) playbackItemState.maxTimeInMilliseconds.toInt() else DEFAULT_MAX_PROGRESS + val progress = if (enabled) playbackItemState.currentTimeInMilliseconds.toInt() else DEFAULT_MIN_PROGRESS + binding.progressBar.isEnabled = enabled + binding.progressBar.max = max + binding.progressBar.progress = progress + binding.tvElapsed.text = if (enabled) formatTime(progress, max) else INDETERMINATE_TIME + binding.tvTotalTime.text = if (enabled) formatTime(max, max) else INDETERMINATE_TIME + } + + private fun formatTime(current: Int, max: Int): String { + val seconds = current / MILLISECONDS_IN_SECOND + val minutes = seconds / SECONDS_IN_MINUTE + val hours = minutes / MINUTES_IN_HOUR + return if (max >= MILLISECONDS_IN_HOUR) { + "%02d:%02d:%02d".format(hours, minutes % MINUTES_IN_HOUR, seconds % SECONDS_IN_MINUTE) + } else { + "%02d:%02d".format(minutes, seconds % SECONDS_IN_MINUTE) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt new file mode 100644 index 000000000000..51a917b8ad0f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -0,0 +1,224 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.pager + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.fragment.app.FragmentManager +import androidx.viewpager.widget.ViewPager +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +import com.nextcloud.client.player.ui.pager.adapter.AbstractFragmentPagerAdapter +import com.nextcloud.client.player.ui.pager.adapter.DefaultFragmentPagerAdapter +import com.nextcloud.client.player.ui.pager.adapter.InfiniteFragmentPagerAdapter +import com.nextcloud.client.player.util.calculateShift +import com.nextcloud.client.player.util.rotate +import com.owncloud.android.R + +class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + LinearLayout(context, attrs) { + private val viewPager: ViewPager + private lateinit var modeStrategy: ModeStrategy + private lateinit var adapter: AbstractFragmentPagerAdapter + private lateinit var onPageChangeListener: OnPageChangeListener + private var playerPagerListener: PlayerPagerListener? = null + private var currentPosition = -1 + private var shift = -1 + private var restoredShift = -1 + + init { + inflate(context, R.layout.player_pager, this) + viewPager = findViewById(R.id.viewPager) + } + + fun initialize( + fragmentManager: FragmentManager, + mode: PlayerPagerMode, + fragmentFactory: PlayerPagerFragmentFactory + ) { + modeStrategy = createModeStrategy(mode) + adapter = modeStrategy.createAdapter(fragmentManager, fragmentFactory) + viewPager.setAdapter(adapter) + onPageChangeListener = modeStrategy.createListener() + } + + private fun createModeStrategy(mode: PlayerPagerMode): ModeStrategy = when (mode) { + PlayerPagerMode.DEFAULT -> FiniteModeStrategy() + PlayerPagerMode.INFINITE -> InfiniteModeStrategy() + } + + fun setPlayerPagerListener(playerPagerListener: PlayerPagerListener?) { + this.playerPagerListener = playerPagerListener + } + + override fun onSaveInstanceState(): Parcelable { + val state = super.onSaveInstanceState() + val infiniteViewPagerState = InfiniteViewPagerState(state) + infiniteViewPagerState.shiftedPosition = shift + return infiniteViewPagerState + } + + override fun onRestoreInstanceState(state: Parcelable?) { + val restoredState: InfiniteViewPagerState = state as InfiniteViewPagerState + super.onRestoreInstanceState(restoredState.superState) + restoredShift = restoredState.shiftedPosition + } + + fun getItems(): List = adapter.getEntities() + + fun setItems(items: List) { + var items = if (restoredShift != -1) shiftRestoredPosition(items) else items + + val calculatedCurrentPositionWithOffsetIfNeeded = + modeStrategy.getCurrentPosition(adapter.count, currentPosition) + + var currentItem: T? = null + if (calculatedCurrentPositionWithOffsetIfNeeded >= 0 && + currentItemPositionsNotTheSameAfterShuffleMatch(calculatedCurrentPositionWithOffsetIfNeeded) + ) { + currentItem = adapter.getEntities()[calculatedCurrentPositionWithOffsetIfNeeded] + items = calculateShiftAndRotateList(items, calculatedCurrentPositionWithOffsetIfNeeded, currentItem) + } + + adapter.setEntities(items) + if (currentItem != null) { + adapter.setCurrentEntity(if (!items.isEmpty()) currentItem else null) + } + + notifyDataSetChangedWithoutCallingListener() + setCurrentItem(currentItem, false) + } + + private fun currentItemPositionsNotTheSameAfterShuffleMatch(calculatedCurrentPosition: Int): Boolean = + adapter.getEntities().isEmpty() && + this.currentPosition >= 0 && + calculatedCurrentPosition < adapter.getEntities().size + + private fun calculateShiftAndRotateList( + items: List, + calculatedCurrentPositionWithOffsetForInfinityStrategy: Int, + currentItem: T? + ): List { + shift = items.calculateShift(calculatedCurrentPositionWithOffsetForInfinityStrategy, currentItem) + return items.rotate(shift) + } + + private fun notifyDataSetChangedWithoutCallingListener() { + viewPager.removeOnPageChangeListener(onPageChangeListener) + adapter.notifyDataSetChanged() + viewPager.addOnPageChangeListener(onPageChangeListener) + } + + private fun shiftRestoredPosition(items: List): List { + shift = restoredShift + restoredShift = -1 + return items.rotate(shift) + } + + fun setCurrentItem(item: T?) { + setCurrentItem(item, true) + } + + private fun setCurrentItem(item: T?, smoothScroll: Boolean) { + currentPosition = item?.let(adapter::getEntityIndex) ?: -1 + if (currentPosition != -1 && viewPager.currentItem != currentPosition) { + viewPager.removeOnPageChangeListener(onPageChangeListener) + viewPager.setCurrentItem(currentPosition, smoothScroll) + viewPager.addOnPageChangeListener(onPageChangeListener) + } + } + + private inner class DefaultOnPageChangeListener : OnPageChangeListener { + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit + + override fun onPageSelected(position: Int) { + playerPagerListener?.onSwitchToItem(adapter.getEntityForPosition(position)) + } + + override fun onPageScrollStateChanged(state: Int) = Unit + } + + private inner class InfinityOnPageChangeListener : OnPageChangeListener { + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit + + override fun onPageSelected(position: Int) { + if (position == 0) { + viewPager.setCurrentItem(adapter.count - 2, false) + return + } + if (position >= adapter.count - 1) { + viewPager.setCurrentItem(1, false) + return + } + playerPagerListener?.onSwitchToItem(adapter.getEntityForPosition(position)) + } + + override fun onPageScrollStateChanged(state: Int) = Unit + } + + private interface ModeStrategy { + fun createAdapter( + fragmentManager: FragmentManager, + fragmentFactory: PlayerPagerFragmentFactory + ): AbstractFragmentPagerAdapter + + fun createListener(): OnPageChangeListener + + fun getCurrentPosition(itemCount: Int, position: Int): Int + } + + private inner class FiniteModeStrategy : ModeStrategy { + override fun createAdapter( + fragmentManager: FragmentManager, + fragmentFactory: PlayerPagerFragmentFactory + ): AbstractFragmentPagerAdapter = DefaultFragmentPagerAdapter(fragmentManager, fragmentFactory) + + override fun createListener(): OnPageChangeListener = DefaultOnPageChangeListener() + + override fun getCurrentPosition(itemCount: Int, position: Int): Int = position + } + + private inner class InfiniteModeStrategy : ModeStrategy { + override fun createAdapter( + fragmentManager: FragmentManager, + fragmentFactory: PlayerPagerFragmentFactory + ): AbstractFragmentPagerAdapter = InfiniteFragmentPagerAdapter(fragmentManager, fragmentFactory) + + override fun createListener(): OnPageChangeListener = InfinityOnPageChangeListener() + + override fun getCurrentPosition(itemCount: Int, position: Int): Int = + if (itemCount > 1) position - 1 else position + } + + class InfiniteViewPagerState : BaseSavedState { + var shiftedPosition: Int = 0 + + constructor(superState: Parcelable?) : super(superState) + + constructor(parcel: Parcel) : super(parcel) { + shiftedPosition = parcel.readInt() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(shiftedPosition) + } + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + + override fun createFromParcel(parcel: Parcel): InfiniteViewPagerState = InfiniteViewPagerState(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt new file mode 100644 index 000000000000..d8c439a04973 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.pager + +import androidx.fragment.app.Fragment + +interface PlayerPagerFragmentFactory { + fun create(item: T): Fragment +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt new file mode 100644 index 000000000000..98e2757e2d23 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.pager + +fun interface PlayerPagerListener { + fun onSwitchToItem(item: T) +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt new file mode 100644 index 000000000000..c694d0ffea4c --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.pager + +enum class PlayerPagerMode { + DEFAULT, + INFINITE +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt new file mode 100644 index 000000000000..de7776511e63 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt @@ -0,0 +1,81 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.pager.adapter + +import android.view.ViewGroup +import androidx.core.util.Pair +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter + +abstract class AbstractFragmentPagerAdapter(fragmentManager: FragmentManager) : + FragmentStatePagerAdapter(fragmentManager) { + protected var currentEntities = mutableListOf() + private var currentEntity: T? = null + private val cachedItems = mutableListOf>() + + abstract fun getEntities(): List + + abstract fun setEntities(entities: List) + + abstract fun getEntityIndex(entity: T): Int + + protected abstract fun getLinkedEntity(position: Int): T + + fun getEntityForPosition(position: Int): T = currentEntities[position] + + fun setCurrentEntity(entity: T?) { + currentEntity = entity + } + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val fragment = super.instantiateItem(container, position) as Fragment + val linkedEntity = getLinkedEntity(position) + if (!findAndReplace(fragment, linkedEntity)) { + cachedItems.add(Pair(linkedEntity, fragment)) + } + return fragment + } + + private fun findAndReplace(fragment: Fragment, linkedEntity: T): Boolean { + for (pair in cachedItems) { + if (pair.first == linkedEntity) { + val newPair = Pair(pair.first, fragment) + cachedItems.add(newPair) + cachedItems.remove(pair) + return true + } + } + return false + } + + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + for (pair in cachedItems) { + if (pair.second == `object`) { + cachedItems.remove(pair) + break + } + } + super.destroyItem(container, position, `object`) + } + + override fun getItemPosition(`object`: Any): Int { + for (pair in cachedItems) { + if (pair.second == `object`) { + return if (currentEntity != null && currentEntity == pair.first) { + super.getItemPosition(`object`) + } else { + POSITION_NONE + } + } + } + return POSITION_NONE + } + + override fun getCount(): Int = currentEntities.size +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt new file mode 100644 index 000000000000..586936eb93be --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.pager.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory + +class DefaultFragmentPagerAdapter( + fragmentManager: FragmentManager, + private val fragmentFactory: PlayerPagerFragmentFactory +) : AbstractFragmentPagerAdapter(fragmentManager) { + + override fun getEntities(): List = currentEntities + + override fun setEntities(entities: List) { + this.currentEntities = entities.toMutableList() + notifyDataSetChanged() + } + + override fun getEntityIndex(entity: T): Int = currentEntities.indexOf(entity) + + override fun getLinkedEntity(position: Int): T = currentEntities[position] + + override fun getItem(position: Int): Fragment = fragmentFactory.create(currentEntities[position]) +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt new file mode 100644 index 000000000000..a1b3a5105378 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.pager.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory + +class InfiniteFragmentPagerAdapter( + fragmentManager: FragmentManager, + private val fragmentFactory: PlayerPagerFragmentFactory +) : AbstractFragmentPagerAdapter(fragmentManager) { + + override fun getEntities(): List = if (currentEntities.size > + 1 + ) { + removeStubs(currentEntities) + } else { + currentEntities + } + + override fun setEntities(entities: List) { + this.currentEntities = if (entities.size > 1) { + addStubs(entities) + } else { + entities.toMutableList() + } + notifyDataSetChanged() + } + + override fun getEntityIndex(entity: T): Int = if (currentEntities.size > 1) { + val entities = removeStubs(currentEntities) + val index = entities.indexOf(entity) + if (index != -1) index + 1 else index + } else { + currentEntities.indexOf(entity) + } + + override fun getLinkedEntity(position: Int): T { + val entities = getEntities() + return when (position) { + 0 -> entities[entities.size - 1] + entities.size + 1 -> entities[0] + else -> entities[position - 1] + } + } + + private fun addStubs(sources: List): MutableList { + val result = sources.toMutableList() + result.add(0, result[result.size - 1]) + result.add(result[1]) + return result + } + + private fun removeStubs(sources: List): MutableList { + val result = sources.toMutableList() + result.removeAt(0) + result.removeAt(result.size - 1) + return result + } + + override fun getItem(position: Int): Fragment = fragmentFactory.create(currentEntities[position]) +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt new file mode 100644 index 000000000000..9e339326efdd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -0,0 +1,131 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.video + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.VideoSize +import com.nextcloud.client.player.util.getDisplayHeight +import com.nextcloud.client.player.util.getDisplayWidth +import com.owncloud.android.R +import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class VideoFileFragment : + Fragment(), + PlaybackModel.Listener { + + companion object { + private const val ARGUMENT_FILE = "ARGUMENT_FILE" + + fun createInstance(file: PlaybackFile) = VideoFileFragment().apply { + arguments = bundleOf(ARGUMENT_FILE to file) + } + } + + @Inject + lateinit var playerModel: PlaybackModel + + @Inject + lateinit var thumbnailLoader: ThumbnailLoader + + private lateinit var file: PlaybackFile + + private lateinit var binding: PlayerVideoFileFragmentBinding + + private var previousVideoSize: VideoSize? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AndroidSupportInjection.inject(this) + this.file = arguments?.getSerializable(ARGUMENT_FILE) as PlaybackFile + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = PlayerVideoFileFragmentBinding.inflate(inflater, container, false) + loadFileThumbnail() + return binding.root + } + + override fun onStart() { + super.onStart() + render(playerModel.state.getOrNull()) + playerModel.addListener(this) + } + + override fun onStop() { + playerModel.removeListener(this) + super.onStop() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + private fun loadFileThumbnail() { + viewLifecycleOwner.lifecycleScope.launch { + val context = context ?: return@launch + val thumbnailSize = context.resources.getDimension(R.dimen.player_album_cover_size) + val thumbnail = thumbnailLoader.await(context, file, thumbnailSize.toInt(), thumbnailSize.toInt()) + thumbnail?.let(binding.thumbnail::setImageBitmap) + } + } + + private fun render(state: PlaybackState?) { + val currentItemState = state?.currentItemState + if (currentItemState?.file == file) { + showVideo(currentItemState.videoSize) + } else { + binding.surfaceView.visibility = View.GONE + if (currentItemState == null) { + playerModel.setVideoSurfaceView(null) + } + } + } + + private fun showVideo(videoSize: VideoSize?) { + playerModel.setVideoSurfaceView(binding.surfaceView) + binding.surfaceView.visibility = View.VISIBLE + binding.surfaceView.alpha = if (videoSize != null) 1f else 0f + + if (videoSize != null && previousVideoSize != videoSize) { + previousVideoSize = videoSize + setVideoSize(videoSize.width, videoSize.height) + } + } + + private fun setVideoSize(videoWidth: Int, videoHeight: Int) { + val screenWidth = requireContext().getDisplayWidth() + val screenHeight = requireContext().getDisplayHeight() + val screenProportion = screenWidth.toFloat() / screenHeight.toFloat() + val videoProportion = videoWidth.toFloat() / videoHeight.toFloat() + + val layoutParams = binding.surfaceView.layoutParams + if (screenProportion < videoProportion) { + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + layoutParams.height = (screenWidth.toFloat() / videoProportion).toInt() + } else { + layoutParams.width = (videoProportion * screenHeight.toFloat()).toInt() + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + } + + binding.surfaceView.layoutParams = layoutParams + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt new file mode 100644 index 000000000000..fbff0021ee02 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.video + +import androidx.fragment.app.Fragment +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory + +class VideoFileFragmentFactory : PlayerPagerFragmentFactory { + + override fun create(item: PlaybackFile): Fragment = VideoFileFragment.createInstance(item) +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt new file mode 100644 index 000000000000..aeaf9ead341b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt @@ -0,0 +1,102 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.ui.video + +import android.content.Context +import android.view.MotionEvent +import android.view.WindowInsets +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.ui.PlayerView +import com.owncloud.android.R +import dagger.android.HasAndroidInjector +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class VideoPlayerView(context: Context) : PlayerView(context) { + + companion object { + private const val HIDE_CONTROLS_DELAY = 5000L + } + + override val layoutRes get() = R.layout.player_video_view + + override val fragmentFactory get() = VideoFileFragmentFactory() + + private var hideControlsTimerJob: Job? = null + + override fun inject(context: Context) { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + } + + override fun onStart() { + super.onStart() + showControls() + } + + override fun onStop() { + super.onStop() + cancelHideControlsTimer() + playbackModel.setVideoSurfaceView(null) + } + + override fun onApplyWindowInsets(windowInsets: WindowInsets): WindowInsets? { + val windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets) + val insets = windowInsetsCompat.getInsets(Type.systemBars() or Type.displayCutout()) + + topBar.setPadding(insets.left, insets.top, insets.right, 0) + playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom) + + windowWrapper.setupStatusBar(R.color.player_video_toolbar_background_color, false) + windowWrapper.setupNavigationBar(R.color.player_video_control_view_background_color, false) + + return WindowInsetsCompat.CONSUMED.toWindowInsets() + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { + val isTouchOutsideControls = event.y < playerControlView.y && event.y > topBar.height + when { + !playerControlView.isVisible -> showControls() + isTouchOutsideControls -> hideControls() + else -> restartHideControlsTimer() + } + } + return super.dispatchTouchEvent(event) + } + + fun showControls() { + windowWrapper.showSystemBars() + topBar.visibility = VISIBLE + playerControlView.visibility = VISIBLE + restartHideControlsTimer() + } + + fun hideControls() { + windowWrapper.hideSystemBars() + topBar.visibility = GONE + playerControlView.visibility = GONE + cancelHideControlsTimer() + } + + private fun restartHideControlsTimer() { + hideControlsTimerJob?.cancel() + hideControlsTimerJob = activity.lifecycleScope.launch { + delay(HIDE_CONTROLS_DELAY) + hideControls() + } + } + + private fun cancelHideControlsTimer() { + hideControlsTimerJob?.cancel() + hideControlsTimerJob = null + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt new file mode 100644 index 000000000000..ae4ce725a974 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.util + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow + +fun ContentResolver.observeContentChanges(uri: Uri, notifyForDescendants: Boolean) = callbackFlow { + val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + trySend(selfChange) + } + } + registerContentObserver(uri, notifyForDescendants, contentObserver) + awaitClose { unregisterContentObserver(contentObserver) } +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/Context.kt b/app/src/main/java/com/nextcloud/client/player/util/Context.kt new file mode 100644 index 000000000000..5ebc3083d5f0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/Context.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.util + +import android.app.AppOpsManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Process + +fun Context.isPictureInPictureAllowed(): Boolean { + if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { + val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager + appOpsManager?.let { + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + it.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) + } else { + @Suppress("DEPRECATION") + it.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) + } + return mode == AppOpsManager.MODE_ALLOWED + } + } + return false +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt b/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt new file mode 100644 index 000000000000..7e3b2fafc630 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.util + +import android.content.res.ColorStateList +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.core.widget.ImageViewCompat + +fun ImageView.setTint(@ColorRes colorRes: Int) { + val color = ContextCompat.getColor(context, colorRes) + ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(color)) +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/List.kt b/app/src/main/java/com/nextcloud/client/player/util/List.kt new file mode 100644 index 000000000000..5084a1c27059 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/List.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.util + +import java.util.Collections + +fun List.calculateShift(targetIndex: Int, item: T?): Int { + val currentIndex = indexOf(item) + return if (currentIndex >= 0 && currentIndex != targetIndex) { + (size - currentIndex + targetIndex) % size + } else { + 0 + } +} + +fun List.rotate(shift: Int): List { + val copy = ArrayList(this) + Collections.rotate(copy, shift) + return copy +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt b/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt new file mode 100644 index 000000000000..91890a763812 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.util + +import android.os.Handler +import android.os.Looper + +class PeriodicAction(private val periodicIntervalInMilliseconds: Long, private val action: () -> Unit) { + private val handler = Handler(Looper.getMainLooper()) + + private val runnable = Runnable { + action.invoke() + start() + } + + fun start() { + stop() + handler.postDelayed(runnable, periodicIntervalInMilliseconds) + } + + fun stop() { + handler.removeCallbacks(runnable) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt new file mode 100644 index 000000000000..e8e1b1818005 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +@file:JvmName("ScreenUtils") + +package com.nextcloud.client.player.util + +import android.content.Context +import android.os.Build +import android.util.DisplayMetrics +import android.view.WindowManager +import android.view.WindowMetrics +import androidx.annotation.RequiresApi + +fun Context.getDisplayWidth(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getWindowMetrics().bounds.width() +} else { + getDisplayMetrics().widthPixels +} + +fun Context.getDisplayHeight(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getWindowMetrics().bounds.height() +} else { + getDisplayMetrics().heightPixels +} + +@RequiresApi(Build.VERSION_CODES.R) +fun Context.getWindowMetrics(): WindowMetrics { + val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + return windowManager.currentWindowMetrics +} + +@Suppress("DEPRECATION") +fun Context.getDisplayMetrics(): DisplayMetrics { + val displayMetrics = DisplayMetrics() + val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + windowManager.defaultDisplay.getRealMetrics(displayMetrics) + return displayMetrics +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt new file mode 100644 index 000000000000..69d80775667d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.util + +import android.os.Build +import android.view.Window +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +private const val LUMINANCE_THRESHOLD = 0.5 + +class WindowWrapper(private val window: Window) { + private val context = window.context + private val insetsController = WindowCompat.getInsetsController(window, window.decorView) + + fun showSystemBars() { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + + fun hideSystemBars() { + insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + } + + fun setupStatusBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) { + val backgroundColor = ContextCompat.getColor(context, backgroundColorRes) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.setStatusBarContrastEnforced(contrastEnforced) + } + insetsController.isAppearanceLightStatusBars = isLightColor(backgroundColor) + } + + fun setupNavigationBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) { + val backgroundColor = ContextCompat.getColor(context, backgroundColorRes) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.setNavigationBarContrastEnforced(contrastEnforced) + } + window.navigationBarColor = backgroundColor + insetsController.isAppearanceLightNavigationBars = isLightColor(backgroundColor) + } + + private fun isLightColor(@ColorInt color: Int): Boolean = ColorUtils.calculateLuminance(color) > LUMINANCE_THRESHOLD +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 3bbd25b3ffa9..81a550eb9725 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2002,6 +2002,35 @@ private ArrayList prepareRemoveSharesInFile( } + public List getShares() { + String selection = ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + " = ?"; + String[] selectionArgs = new String[]{user.getAccountName()}; + + Cursor cursor = null; + Uri uri = ProviderTableMeta.CONTENT_URI_SHARE; + if (getContentResolver() != null) { + cursor = getContentResolver().query(uri, null, selection, selectionArgs, null); + } else { + try { + cursor = getContentProviderClient().query(uri, null, selection, selectionArgs, null); + } catch (RemoteException e) { + Log_OC.e(TAG, "Could not get list of shares: " + e.getMessage(), e); + } + } + + ArrayList shares = new ArrayList<>(); + if (cursor != null) { + if (cursor.moveToFirst()) { + do { + shares.add(createShareInstance(cursor)); + } while (cursor.moveToNext()); + } + cursor.close(); + } + + return shares; + } + public List getSharesWithForAFile(String filePath, String accountName) { String selection = ProviderTableMeta.OCSHARES_PATH + AND + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + AND @@ -2879,6 +2908,17 @@ public List getAllFiles() { return folderContent; } + public List getFavoriteFiles() { + List fileEntities = fileDao.getFavoriteFiles(user.getAccountName()); + List favoriteFiles = new ArrayList<>(fileEntities.size()); + + for (FileEntity fileEntity : fileEntities) { + favoriteFiles.add(createFileInstance(fileEntity)); + } + + return favoriteFiles; + } + private String getString(Cursor cursor, String columnName) { return cursor.getString(cursor.getColumnIndexOrThrow(columnName)); } diff --git a/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java index 797de413eca6..80ddd5f02a00 100644 --- a/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java +++ b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java @@ -35,7 +35,9 @@ import com.owncloud.android.utils.MimeType; import java.util.ArrayList; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; import javax.inject.Inject; @@ -81,6 +83,9 @@ public class FileContentProvider extends ContentProvider { private static final String[] PROJECTION_FILE_PATH_AND_OWNER = new String[]{ ProviderTableMeta._ID, ProviderTableMeta.FILE_PATH, ProviderTableMeta.FILE_ACCOUNT_OWNER }; + private static final String[] PROJECTION_PARENT_ID = new String[]{ + ProviderTableMeta._ID, ProviderTableMeta.FILE_PARENT + }; @Inject protected Clock clock; @@ -96,15 +101,21 @@ public int delete(@NonNull Uri uri, String where, String[] whereArgs) { } int count; + Set parentIds; SupportSQLiteDatabase db = mDbHelper.getWritableDatabase(); db.beginTransaction(); try { + parentIds = queryParentIds(db, uri, where, whereArgs); count = delete(db, uri, where, whereArgs); db.setTransactionSuccessful(); } finally { db.endTransaction(); } mContext.getContentResolver().notifyChange(uri, null); + for (long parentId : parentIds) { + Uri parentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, parentId); + mContext.getContentResolver().notifyChange(parentUri, null); + } return count; } @@ -218,6 +229,11 @@ public Uri insert(@NonNull Uri uri, ContentValues values) { db.endTransaction(); } mContext.getContentResolver().notifyChange(newUri, null); + Long parentId = values.getAsLong(ProviderTableMeta.FILE_PARENT); + if (parentId != null) { + Uri parentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, parentId); + mContext.getContentResolver().notifyChange(parentUri, null); + } return newUri; } @@ -556,15 +572,21 @@ public int update(@NonNull Uri uri, ContentValues values, String selection, Stri } int count; + Set parentIds; SupportSQLiteDatabase db = mDbHelper.getWritableDatabase(); db.beginTransaction(); try { + parentIds = queryParentIds(db, uri, selection, selectionArgs); count = update(db, uri, values, selection, selectionArgs); db.setTransactionSuccessful(); } finally { db.endTransaction(); } mContext.getContentResolver().notifyChange(uri, null); + for (long parentId : parentIds) { + Uri parentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, parentId); + mContext.getContentResolver().notifyChange(parentUri, null); + } return count; } @@ -595,6 +617,27 @@ private int update(SupportSQLiteDatabase db, Uri uri, ContentValues values, Stri }; } + private Set queryParentIds(SupportSQLiteDatabase db, Uri uri, String where, String... whereArgs) { + Set result = new HashSet<>(); + int uriMatch = mUriMatcher.match(uri); + if (uriMatch == ROOT_DIRECTORY || mUriMatcher.match(uri) == DIRECTORY || mUriMatcher.match(uri) == SINGLE_FILE) { + try (Cursor cursor = query(db, uri, PROJECTION_PARENT_ID, where, whereArgs, null)) { + if (cursor.moveToFirst()) { + do { + int parentIdColumnIndex = cursor.getColumnIndex(ProviderTableMeta.FILE_PARENT); + if (parentIdColumnIndex != -1 && !cursor.isNull(parentIdColumnIndex)) { + long parentId = cursor.getLong(parentIdColumnIndex); + result.add(parentId); + } + } while (cursor.moveToNext()); + } + } catch (Exception e) { + Log_OC.d(TAG, "Error querying parent IDs", e); + } + } + return result; + } + @NonNull @Override public ContentProviderResult[] applyBatch(@NonNull ArrayList operations) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java index 9e6c4637f841..c64811a69ed5 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -551,6 +551,10 @@ private void setFilenameAndExtension(ListGridItemViewHolder holder, OCFile file) } else { handleListMode(holder, filename, pair); } + + if (holder.getPlayerProgressIndicator() != null) { + holder.getPlayerProgressIndicator().setFile(file); + } } private void handleGridMode(String filename, OCFileListGridItemViewHolder holder, Pair filenamePair, OCFile file) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt index 351c9258583b..65aaeb970944 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt @@ -15,6 +15,7 @@ import android.widget.TextView import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.nextcloud.client.player.ui.PlayerProgressIndicator import com.owncloud.android.databinding.GridItemBinding class OCFileListGridItemViewHolder(var binding: GridItemBinding) : @@ -34,6 +35,8 @@ class OCFileListGridItemViewHolder(var binding: GridItemBinding) : } else { null } + override val playerProgressIndicator: PlayerProgressIndicator + get() = binding.playerProgressIndicator override val thumbnail: ImageView get() = binding.thumbnail diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 31cdbeb107ec..e1534cf029cf 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -1481,6 +1481,10 @@ public OCFile getCurrentFile() { return mFile; } + public SearchType getCurrentSearchType() { + return currentSearchType; + } + /** * Calls {@link OCFileListFragment#listDirectory(OCFile, boolean)} with a null parameter */ diff --git a/app/src/main/res/drawable-v33/player_ic_notification_audio.xml b/app/src/main/res/drawable-v33/player_ic_notification_audio.xml new file mode 100644 index 000000000000..49bf8fc8e7d5 --- /dev/null +++ b/app/src/main/res/drawable-v33/player_ic_notification_audio.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-v33/player_ic_notification_video.xml b/app/src/main/res/drawable-v33/player_ic_notification_video.xml new file mode 100644 index 000000000000..747fe4f3e90f --- /dev/null +++ b/app/src/main/res/drawable-v33/player_ic_notification_video.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_ic_audio.xml b/app/src/main/res/drawable/player_ic_audio.xml new file mode 100644 index 000000000000..f38528439cbc --- /dev/null +++ b/app/src/main/res/drawable/player_ic_audio.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_close.xml b/app/src/main/res/drawable/player_ic_close.xml new file mode 100644 index 000000000000..58190e40fcf8 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_close.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_notification_audio.xml b/app/src/main/res/drawable/player_ic_notification_audio.xml new file mode 100644 index 000000000000..75ee62145e8f --- /dev/null +++ b/app/src/main/res/drawable/player_ic_notification_audio.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_ic_notification_video.xml b/app/src/main/res/drawable/player_ic_notification_video.xml new file mode 100644 index 000000000000..def8fbc18cb7 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_notification_video.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_ic_pause.xml b/app/src/main/res/drawable/player_ic_pause.xml new file mode 100644 index 000000000000..48f169b40fd1 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_pause.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/player_ic_play.xml b/app/src/main/res/drawable/player_ic_play.xml new file mode 100644 index 000000000000..0b3fe31771c8 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_play.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/player_ic_repeat.xml b/app/src/main/res/drawable/player_ic_repeat.xml new file mode 100644 index 000000000000..5a8b3c1eca35 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_repeat.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_shuffle.xml b/app/src/main/res/drawable/player_ic_shuffle.xml new file mode 100644 index 000000000000..bdbd7182d917 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_shuffle.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_skip_next.xml b/app/src/main/res/drawable/player_ic_skip_next.xml new file mode 100644 index 000000000000..bad7839dfb7a --- /dev/null +++ b/app/src/main/res/drawable/player_ic_skip_next.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_skip_previous.xml b/app/src/main/res/drawable/player_ic_skip_previous.xml new file mode 100644 index 000000000000..088cf2a2b64d --- /dev/null +++ b/app/src/main/res/drawable/player_ic_skip_previous.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_video.xml b/app/src/main/res/drawable/player_ic_video.xml new file mode 100644 index 000000000000..04aa20a161d0 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_video.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_progress_drawable.xml b/app/src/main/res/drawable/player_progress_drawable.xml new file mode 100644 index 000000000000..9d2a7fe5a8b9 --- /dev/null +++ b/app/src/main/res/drawable/player_progress_drawable.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_progress_thumb.xml b/app/src/main/res/drawable/player_progress_thumb.xml new file mode 100644 index 000000000000..e7a6b68f805b --- /dev/null +++ b/app/src/main/res/drawable/player_progress_thumb.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/grid_item.xml b/app/src/main/res/layout/grid_item.xml index 97e1084bdbd6..fe05edce1f7e 100644 --- a/app/src/main/res/layout/grid_item.xml +++ b/app/src/main/res/layout/grid_item.xml @@ -235,5 +235,15 @@ tools:ignore="TouchTargetSizeCheck" tools:visibility="visible" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/list_item.xml b/app/src/main/res/layout/list_item.xml index 96ce1a989ca7..46847db9f216 100644 --- a/app/src/main/res/layout/list_item.xml +++ b/app/src/main/res/layout/list_item.xml @@ -238,6 +238,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_audio_view.xml b/app/src/main/res/layout/player_audio_view.xml new file mode 100644 index 000000000000..712a9f9b31f2 --- /dev/null +++ b/app/src/main/res/layout/player_audio_view.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_control_view.xml b/app/src/main/res/layout/player_control_view.xml new file mode 100644 index 000000000000..772b50e22fd0 --- /dev/null +++ b/app/src/main/res/layout/player_control_view.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_pager.xml b/app/src/main/res/layout/player_pager.xml new file mode 100644 index 000000000000..2b953b9b2dc6 --- /dev/null +++ b/app/src/main/res/layout/player_pager.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/player_video_file_fragment.xml b/app/src/main/res/layout/player_video_file_fragment.xml new file mode 100644 index 000000000000..94e7e6a774e7 --- /dev/null +++ b/app/src/main/res/layout/player_video_file_fragment.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_video_view.xml b/app/src/main/res/layout/player_video_view.xml new file mode 100644 index 000000000000..51dad2a93777 --- /dev/null +++ b/app/src/main/res/layout/player_video_view.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-land/dims.xml b/app/src/main/res/values-land/dims.xml new file mode 100644 index 000000000000..e09cf5424063 --- /dev/null +++ b/app/src/main/res/values-land/dims.xml @@ -0,0 +1,16 @@ + + + + + 56dp + 8dp + 0dp + 8dp + 0dp + 8dp + \ No newline at end of file diff --git a/app/src/main/res/values-large-land/dims.xml b/app/src/main/res/values-large-land/dims.xml new file mode 100644 index 000000000000..07ce8f787cad --- /dev/null +++ b/app/src/main/res/values-large-land/dims.xml @@ -0,0 +1,16 @@ + + + + + 56dp + 24dp + 8dp + 24dp + 16dp + 16dp + \ No newline at end of file diff --git a/app/src/main/res/values-large/dims.xml b/app/src/main/res/values-large/dims.xml new file mode 100644 index 000000000000..3a56e875553b --- /dev/null +++ b/app/src/main/res/values-large/dims.xml @@ -0,0 +1,18 @@ + + + + + 64dp + 12dp + 24dp + 24dp + 48dp + 48dp + 40dp + 72dp + \ No newline at end of file diff --git a/app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt b/app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt new file mode 100644 index 000000000000..7b9fa1d03ff9 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt @@ -0,0 +1,107 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.media3.controller + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import com.nextcloud.client.player.model.state.RepeatMode +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaControllerExtensionsTest { + + private fun item(id: String) = MediaItem.Builder().setMediaId(id).build() + + @Test + fun `indexOfFirst found and not found`() { + val controller = mockk(relaxed = true) + val items = listOf(item("A"), item("B"), item("C")) + + every { controller.mediaItemCount } returns items.size + every { controller.getMediaItemAt(any()) } answers { items[firstArg()] } + + val found = controller.indexOfFirst { it.mediaId == "B" } + val notFound = controller.indexOfFirst { it.mediaId == "X" } + + assertEquals(1, found) + assertEquals(-1, notFound) + } + + @Test + fun `updateMediaItems updates around current`() { + val controller = mockk(relaxed = true) + + every { controller.currentMediaItemIndex } returns 1 + every { controller.currentMediaItem } returns item("B") + every { controller.mediaItemCount } returns 3 + + val new = listOf(item("A"), item("B"), item("D"), item("E")) + + controller.updateMediaItems(new) + + verifyOrder { + controller.removeMediaItems(2, 3) + controller.addMediaItems(listOf(new[2], new[3])) + controller.removeMediaItems(0, 1) + controller.addMediaItems(0, listOf(new[0])) + controller.replaceMediaItem(1, new[1]) + } + + verify(exactly = 0) { + controller.setMediaItems(any>()) + } + } + + @Test + fun `updateMediaItems falls back to setMediaItems when no match`() { + val controller = mockk(relaxed = true) + + every { controller.currentMediaItemIndex } returns 0 + every { controller.currentMediaItem } returns item("X") + every { controller.mediaItemCount } returns 2 + + val new = listOf(item("A"), item("B")) + + controller.updateMediaItems(new) + + verify { + controller.setMediaItems(new) + } + + verify(exactly = 0) { + controller.removeMediaItems(any(), any()) + controller.addMediaItems(any>()) + controller.addMediaItems(any(), any>()) + controller.replaceMediaItem(any(), any()) + } + } + + @Test + fun `setRepeatMode maps to Player constants`() { + val controller = mockk(relaxed = true) + + controller.setRepeatMode(RepeatMode.SINGLE) + verify { controller.repeatMode = Player.REPEAT_MODE_ONE } + + clearMocks(controller, answers = false, recordedCalls = true, verificationMarks = true) + + controller.setRepeatMode(RepeatMode.ALL) + verify { controller.repeatMode = Player.REPEAT_MODE_ALL } + + clearMocks(controller, answers = false, recordedCalls = true, verificationMarks = true) + + controller.setRepeatMode(RepeatMode.OFF) + verify { controller.repeatMode = Player.REPEAT_MODE_OFF } + } +} diff --git a/app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt b/app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt new file mode 100644 index 000000000000..f731dd827113 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.error + +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import org.junit.Assert +import org.junit.Test + +class DefaultPlaybackErrorStrategyTest { + private val strategy = DefaultPlaybackErrorStrategy() + + @Test + fun `switchToNextSource returns false when one file in queue`() { + val state = createState(mockWithName("a"), mockWithName("a")) + val switchToNext = strategy.switchToNextSource(RuntimeException(), state) + Assert.assertFalse(switchToNext) + } + + @Test + fun `switchToNextSource returns false when current file is last`() { + val state = createState(mockWithName("b"), mockWithName("a"), mockWithName("b")) + val switchToNext = strategy.switchToNextSource(RuntimeException(), state) + Assert.assertFalse(switchToNext) + } + + @Test + fun `switchToNextSource returns true when current file is not last`() { + val state = createState(mockWithName("b"), mockWithName("a"), mockWithName("a")) + val switchToNext = strategy.switchToNextSource(RuntimeException(), state) + Assert.assertTrue(switchToNext) + } + + private fun createState(currentFile: PlaybackFile, vararg files: PlaybackFile): PlaybackState = PlaybackState( + currentFiles = files.toList(), + currentItemState = currentFile.toPlaybackItemState(), + repeatMode = RepeatMode.OFF, + shuffle = false + ) + + private fun mockWithName(name: String): PlaybackFile = PlaybackFile( + id = name, + uri = name, + name = "fakeUri:///$name", + mimeType = "audio/mp3", + contentLength = 0, + lastModified = 0, + isFavorite = false + ) + + private fun PlaybackFile.toPlaybackItemState() = PlaybackItemState( + file = this, + playerState = PlayerState.NONE, + metadata = null, + videoSize = null, + currentTimeInMilliseconds = 0L, + maxTimeInMilliseconds = 0L + ) +} diff --git a/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt b/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt new file mode 100644 index 000000000000..3a1c786b99f8 --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt @@ -0,0 +1,145 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.model.file + +import com.owncloud.android.utils.FileSortOrder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaybackFilesComparatorTest { + + @Test + fun `NONE comparator always returns 0`() { + val a = file("a") + val b = file("b") + assertEquals(0, PlaybackFilesComparator.NONE.compare(a, b)) + assertEquals(0, PlaybackFilesComparator.NONE.compare(b, a)) + } + + @Test + fun `FAVORITE uses natural alphanumeric ordering`() { + val list = listOf(file("file10"), file("file2"), file("file1")) + val sorted = list.sortedWith(PlaybackFilesComparator.FAVORITE) + assertEquals(listOf("file1", "file2", "file10"), sorted.map { it.name }) + } + + @Test + fun `GALLERY sorts by lastModified descending`() { + val a = file("a", modified = 100) + val b = file("b", modified = 300) + val c = file("c", modified = 200) + val sorted = listOf(a, b, c).sortedWith(PlaybackFilesComparator.GALLERY) + assertEquals(listOf("b", "c", "a"), sorted.map { it.name }) + } + + @Test + fun `SHARED sorts by lastModified descending (same as GALLERY)`() { + val a = file("a", modified = 1) + val b = file("b", modified = 5) + val sorted = listOf(a, b).sortedWith(PlaybackFilesComparator.SHARED) + assertEquals(listOf("b", "a"), sorted.map { it.name }) + } + + @Test + fun `Folder comparator ALPHABET ascending with favorites first`() { + val list = listOf( + file("b2", favorite = true), + file("a10"), + file("a2", favorite = true), + file("a1"), + file("b10", favorite = true) + ) + val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.ALPHABET, isAscending = true) + val sorted = list.sortedWith(cmp) + assertEquals(listOf("a2", "b2", "b10", "a1", "a10"), sorted.map { it.name }) + assertTrue(sorted.take(3).all { it.isFavorite }) + assertTrue(sorted.drop(3).none { it.isFavorite }) + } + + @Test + fun `Folder comparator ALPHABET descending with favorites first`() { + val list = listOf( + file("x1"), + file("x10", favorite = true), + file("x2", favorite = true), + file("x11") + ) + val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.ALPHABET, isAscending = false) + val sorted = list.sortedWith(cmp) + assertEquals(listOf("x10", "x2", "x11", "x1"), sorted.map { it.name }) + } + + @Test + fun `Folder comparator SIZE ascending then favorites`() { + val list = listOf( + file("bigFav", favorite = true, size = 300), + file("smallFav", favorite = true, size = 100), + file("mid", size = 200), + file("tiny", size = 50) + ) + val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.SIZE, isAscending = true) + val sorted = list.sortedWith(cmp) + assertEquals(listOf("smallFav", "bigFav", "tiny", "mid"), sorted.map { it.name }) + } + + @Test + fun `Folder comparator SIZE descending`() { + val list = listOf( + file("a", size = 100), + file("b", size = 500), + file("c", size = 300) + ) + val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.SIZE, isAscending = false) + val sorted = list.sortedWith(cmp) + assertEquals(listOf("b", "c", "a"), sorted.map { it.name }) + } + + @Test + fun `Folder comparator DATE ascending`() { + val list = listOf( + file("newFav", favorite = true, modified = 300), + file("oldFav", favorite = true, modified = 100), + file("old", modified = 50), + file("mid", modified = 200) + ) + val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.DATE, isAscending = true) + val sorted = list.sortedWith(cmp) + assertEquals(listOf("oldFav", "newFav", "old", "mid"), sorted.map { it.name }) + } + + @Test + fun `Folder comparator DATE descending`() { + val list = listOf( + file("a", modified = 100), + file("b", modified = 400), + file("c", modified = 200) + ) + val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.DATE, isAscending = false) + val sorted = list.sortedWith(cmp) + assertEquals(listOf("b", "c", "a"), sorted.map { it.name }) + } + + @Test + fun `Alphanumeric edge cases with leading zeros`() { + val list = listOf(file("track02"), file("track2"), file("track10"), file("track01")) + val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.ALPHABET, isAscending = true) + val sorted = list.sortedWith(cmp) + assertEquals(listOf("track01", "track2", "track02", "track10"), sorted.map { it.name }) + } + + private fun file(name: String, favorite: Boolean = false, size: Long = 0, modified: Long = 0) = PlaybackFile( + id = "id_$name", + uri = "uri_$name", + name = name, + mimeType = "audio/mpeg", + contentLength = size, + lastModified = modified, + isFavorite = favorite + ) +} diff --git a/app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt b/app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt new file mode 100644 index 000000000000..e63642eb071c --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: GPL-2.0 + */ + +package com.nextcloud.client.player.util + +import org.junit.Assert +import org.junit.Test + +class ListExtensionsTest { + private val inputList = listOf(1, 2, 3) + + @Test + fun `calculateShift returns expected shift`() { + val shift = inputList.calculateShift(0, inputList[2]) + Assert.assertEquals(1, shift) + } + + @Test + fun `rotate returns expected list`() { + val expectedList = listOf(3, 1, 2) + val rotatedList = inputList.rotate(1) + Assert.assertEquals(expectedList, rotatedList) + } +} From d22c9c834678ecc692fa1f047b94a3d4d84d88dc Mon Sep 17 00:00:00 2001 From: Alex Kucherenko Date: Fri, 12 Dec 2025 13:18:28 +0200 Subject: [PATCH 02/69] Enhanced player implementation Signed-off-by: alperozturk96 --- .../android/ui/activity/ManageAccountsActivity.kt | 2 ++ .../owncloud/android/ui/dialog/AccountRemovalDialog.kt | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt index 47de3ea71b76..ed4ba6220d40 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt @@ -240,6 +240,7 @@ class ManageAccountsActivity : } override fun showFirstRunActivity() { + stopMediaPlayerAndHidePip() val intent = Intent(applicationContext, FirstRunActivity::class.java).apply { putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true) } @@ -249,6 +250,7 @@ class ManageAccountsActivity : @Suppress("TooGenericExceptionCaught") @SuppressLint("NotifyDataSetChanged") override fun startAccountCreation() { + stopMediaPlayerAndHidePip() val am = AccountManager.get(applicationContext) am.addAccount( MainApp.getAccountType(this), diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt index 595e092e0de8..31dc885fe8c2 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt @@ -40,6 +40,9 @@ class AccountRemovalDialog : @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var playbackModel: PlaybackModel + private var user: User? = null private lateinit var alertDialog: AlertDialog private var _binding: AccountRemovalDialogBinding? = null @@ -125,6 +128,7 @@ class AccountRemovalDialog : */ private fun removeAccount() { user?.let { user -> + stopMediaPlayerAndHidePip() if (binding.radioRequestDeletion.isChecked) { DisplayUtils.startLinkIntent(activity, user.server.uri.toString() + DROP_ACCOUNT_URI) } else { @@ -133,6 +137,10 @@ class AccountRemovalDialog : } } + private fun stopMediaPlayerAndHidePip() { + playbackModel.release() + } + /** * Start avatar generation. */ From 094d7f51292e023ae0b3eac4ba87ac9e34dad102 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:20:12 +0100 Subject: [PATCH 03/69] Rename .java to .kt Signed-off-by: alperozturk96 --- .../nextcloud/client/di/{AppComponent.java => AppComponent.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/nextcloud/client/di/{AppComponent.java => AppComponent.kt} (100%) diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.java b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt similarity index 100% rename from app/src/main/java/com/nextcloud/client/di/AppComponent.java rename to app/src/main/java/com/nextcloud/client/di/AppComponent.kt From 2e6f069c18b5c2a584d67391dbf1ada1fd9e99e6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:20:13 +0100 Subject: [PATCH 04/69] fix build Signed-off-by: alperozturk96 # Conflicts: # app/src/main/res/values/strings.xml --- .../nextcloud/client/database/dao/FileDao.kt | 18 ++- .../com/nextcloud/client/di/AppComponent.kt | 127 +++++++++--------- .../datamodel/FileDataStorageManager.java | 2 +- .../android/ui/activity/DrawerActivity.java | 9 ++ .../android/ui/dialog/AccountRemovalDialog.kt | 1 + .../ui/fragment/OCFileListFragment.java | 4 - app/src/main/res/values/colors.xml | 11 ++ 7 files changed, 93 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt index a30fd9ad2024..1f38173f2bb3 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -85,13 +85,6 @@ interface FileDao { @Query("SELECT * FROM filelist WHERE file_owner = :fileOwner ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") fun getAllFiles(fileOwner: String): List - @Query( - "SELECT * FROM filelist WHERE favorite = 1" + - " AND file_owner = :fileOwner" + - " ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}" - ) - fun getFavoriteFiles(fileOwner: String): List - @Query("SELECT * FROM filelist WHERE path LIKE :pathPattern AND file_owner = :fileOwner ORDER BY path ASC") fun getFolderWithDescendants(pathPattern: String, fileOwner: String): List @@ -176,6 +169,17 @@ interface FileDao { ) suspend fun getFavoriteFiles(fileOwner: String): List + @Query( + """ + SELECT * + FROM filelist + WHERE file_owner = :fileOwner + AND favorite = 1 + ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER} + """ + ) + fun getFavoriteFilesNonBlocking(fileOwner: String): List + @Query("SELECT remote_id FROM filelist WHERE file_owner = :accountName AND remote_id IS NOT NULL") fun getAllRemoteIds(accountName: String): List diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt index 263b4b3f3fbf..2e042d2e6768 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt @@ -4,87 +4,80 @@ * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ - -package com.nextcloud.client.di; - -import android.app.Application; - -import com.nextcloud.appReview.InAppReviewModule; -import com.nextcloud.client.appinfo.AppInfoModule; -import com.nextcloud.client.database.DatabaseModule; -import com.nextcloud.client.device.DeviceModule; -import com.nextcloud.client.integrations.IntegrationsModule; -import com.nextcloud.client.jobs.JobsModule; -import com.nextcloud.client.jobs.download.FileDownloadHelper; -import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver; -import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver; -import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver; -import com.nextcloud.client.jobs.upload.FileUploadHelper; -import com.nextcloud.client.media.BackgroundPlayerService; -import com.nextcloud.client.network.NetworkModule; -import com.nextcloud.client.onboarding.OnboardingModule; -import com.nextcloud.client.player.PlayerModule; -import com.nextcloud.client.preferences.PreferencesModule; -import com.owncloud.android.MainApp; -import com.owncloud.android.media.MediaControlView; -import com.owncloud.android.ui.ThemeableSwitchPreference; -import com.owncloud.android.ui.whatsnew.ProgressIndicator; - -import javax.inject.Singleton; - -import androidx.annotation.OptIn; -import androidx.media3.common.util.UnstableApi; -import dagger.BindsInstance; -import dagger.Component; -import dagger.android.support.AndroidSupportInjectionModule; - -@Component(modules = { - AndroidSupportInjectionModule.class, - AppModule.class, - PreferencesModule.class, - AppInfoModule.class, - NetworkModule.class, - DeviceModule.class, - OnboardingModule.class, - ViewModelModule.class, - JobsModule.class, - IntegrationsModule.class, - InAppReviewModule.class, - ThemeModule.class, - DatabaseModule.class, - DispatcherModule.class, - VariantModule.class, - PlayerModule.class, -}) +package com.nextcloud.client.di + +import android.app.Application +import com.nextcloud.appReview.InAppReviewModule +import com.nextcloud.client.appinfo.AppInfoModule +import com.nextcloud.client.database.DatabaseModule +import com.nextcloud.client.device.DeviceModule +import com.nextcloud.client.integrations.IntegrationsModule +import com.nextcloud.client.jobs.JobsModule +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver +import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver +import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.media.BackgroundPlayerService +import com.nextcloud.client.network.NetworkModule +import com.nextcloud.client.onboarding.OnboardingModule +import com.nextcloud.client.player.PlayerModule +import com.nextcloud.client.preferences.PreferencesModule +import com.owncloud.android.MainApp +import com.owncloud.android.media.MediaControlView +import com.owncloud.android.ui.ThemeableSwitchPreference +import com.owncloud.android.ui.whatsnew.ProgressIndicator +import dagger.BindsInstance +import dagger.Component +import dagger.android.support.AndroidSupportInjectionModule +import javax.inject.Singleton + +@Component( + modules = [AndroidSupportInjectionModule::class, + AppModule::class, + PreferencesModule::class, + AppInfoModule::class, + NetworkModule::class, + DeviceModule::class, + OnboardingModule::class, + ViewModelModule::class, + JobsModule::class, + IntegrationsModule::class, + InAppReviewModule::class, + ThemeModule::class, + DatabaseModule::class, + DispatcherModule::class, + VariantModule::class, + PlayerModule::class + ] +) @Singleton -public interface AppComponent { - - void inject(MainApp app); +interface AppComponent { + fun inject(app: MainApp) - void inject(MediaControlView mediaControlView); + fun inject(mediaControlView: MediaControlView) - @OptIn(markerClass = UnstableApi.class) - void inject(BackgroundPlayerService backgroundPlayerService); + fun inject(backgroundPlayerService: BackgroundPlayerService) - void inject(ThemeableSwitchPreference switchPreference); + fun inject(switchPreference: ThemeableSwitchPreference) - void inject(FileUploadHelper fileUploadHelper); + fun inject(fileUploadHelper: FileUploadHelper) - void inject(FileDownloadHelper fileDownloadHelper); + fun inject(fileDownloadHelper: FileDownloadHelper) - void inject(ProgressIndicator progressIndicator); + fun inject(progressIndicator: ProgressIndicator) - void inject(FileUploadBroadcastReceiver fileUploadBroadcastReceiver); + fun inject(fileUploadBroadcastReceiver: FileUploadBroadcastReceiver) - void inject(OfflineOperationReceiver offlineOperationReceiver); + fun inject(offlineOperationReceiver: OfflineOperationReceiver) - void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver); + fun inject(folderDownloadWorkerReceiver: FolderDownloadWorkerReceiver) @Component.Builder interface Builder { @BindsInstance - Builder application(Application application); + fun application(application: Application): Builder - AppComponent build(); + fun build(): AppComponent } } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 81a550eb9725..ad0ab96937ae 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2909,7 +2909,7 @@ public List getAllFiles() { } public List getFavoriteFiles() { - List fileEntities = fileDao.getFavoriteFiles(user.getAccountName()); + List fileEntities = fileDao.getFavoriteFilesNonBlocking(user.getAccountName()); List favoriteFiles = new ArrayList<>(fileEntities.size()); for (FileEntity fileEntity : fileEntities) { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 0a5f504366fb..1e5351352efc 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -57,6 +57,7 @@ import com.nextcloud.client.files.DeepLinkConstants; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.onboarding.FirstRunActivity; +import com.nextcloud.client.player.model.PlaybackModel; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.common.NextcloudClient; import com.nextcloud.ui.ChooseAccountDialogFragment; @@ -152,6 +153,9 @@ public abstract class DrawerActivity extends ToolbarActivity public static final int REQ_ALL_FILES_ACCESS = 3001; public static final int REQ_MEDIA_ACCESS = 3000; + @Inject + PlaybackModel playbackModel; + /** * Reference to the drawer layout. */ @@ -744,6 +748,7 @@ public void openManageAccounts() { } public void openAddAccount() { + stopMediaPlayerAndHidePip(); if (MDMConfig.INSTANCE.showIntro(this)) { Intent firstRunIntent = new Intent(getApplicationContext(), FirstRunActivity.class); firstRunIntent.putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true); @@ -753,6 +758,10 @@ public void openAddAccount() { } } + protected void stopMediaPlayerAndHidePip() { + playbackModel.release(); + } + private void resetFileDepth() { final var ocFileListFragment = getOCFileListFragment(); if (ocFileListFragment != null) { diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt index 31dc885fe8c2..5fcb62b54640 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt @@ -19,6 +19,7 @@ import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.player.model.PlaybackModel import com.nextcloud.utils.extensions.getParcelableArgument import com.owncloud.android.R import com.owncloud.android.databinding.AccountRemovalDialogBinding diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index e1534cf029cf..31cdbeb107ec 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -1481,10 +1481,6 @@ public OCFile getCurrentFile() { return mFile; } - public SearchType getCurrentSearchType() { - return currentSearchType; - } - /** * Calls {@link OCFileListFragment#listDirectory(OCFile, boolean)} with a null parameter */ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d8edd0a0e602..856fc7efd30e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -88,4 +88,15 @@ #A5A5A5 #EFEFEF + + + @color/color_accent + #111111 + @color/white + @color/white + @color/white + @color/white + #21000000 + #21000000 + #979797 From 0314ceb0711b843169256cfce8318c4d70778c42 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:27:49 +0100 Subject: [PATCH 05/69] fix codacy Signed-off-by: alperozturk96 --- .../nextcloud/client/player/media3/Media3PlaybackModelTest.kt | 1 + .../client/player/model/file/PlaybackFilesRepositoryTest.kt | 1 + .../client/player/ui/control/PlayerControlViewTest.kt | 1 + app/src/main/java/com/nextcloud/client/di/AppComponent.kt | 3 ++- .../com/owncloud/android/ui/activity/ManageAccountsActivity.kt | 2 +- 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt index 49b97b0c18a4..7ea1f8f299bb 100644 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt @@ -39,6 +39,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +@Suppress("TooManyFunctions") class Media3PlaybackModelTest { private lateinit var settings: PlaybackSettings private lateinit var model: PlaybackModel diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt index ff321bb507e5..433a4a339062 100644 --- a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt @@ -36,6 +36,7 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +@Suppress("TooManyFunctions") class PlaybackFilesRepositoryTest { private lateinit var storageManager: FileDataStorageManager private lateinit var preferences: AppPreferences diff --git a/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt index 3e904e08e5b6..e14807792653 100644 --- a/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt @@ -24,6 +24,7 @@ import org.junit.Before import org.junit.Test import java.util.Optional +@Suppress("TooManyFunctions") class PlayerControlViewTest { private lateinit var playbackModel: PlaybackModel diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt index 2e042d2e6768..06aaef9c3776 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt @@ -33,7 +33,8 @@ import dagger.android.support.AndroidSupportInjectionModule import javax.inject.Singleton @Component( - modules = [AndroidSupportInjectionModule::class, + modules = [ + AndroidSupportInjectionModule::class, AppModule::class, PreferencesModule::class, AppInfoModule::class, diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt index ed4ba6220d40..0f383ae5183e 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt @@ -240,7 +240,7 @@ class ManageAccountsActivity : } override fun showFirstRunActivity() { - stopMediaPlayerAndHidePip() + stopMediaPlayerAndHidePip() val intent = Intent(applicationContext, FirstRunActivity::class.java).apply { putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true) } From 9729847c8e2a20d712308d0282282303e5597a91 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:44:58 +0100 Subject: [PATCH 06/69] remove unused activity Signed-off-by: alperozturk96 # Conflicts: # app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt --- app/src/main/AndroidManifest.xml | 10 - .../nextcloud/client/di/ComponentsModule.java | 8 - .../java/com/nextcloud/client/media/Player.kt | 272 ------- .../com/nextcloud/client/media/PlayerError.kt | 9 - .../nextcloud/client/media/PlayerService.kt | 239 ------- .../client/media/PlayerServiceConnection.kt | 122 ---- .../client/media/PlayerStateMachine.kt | 216 ------ .../ui/preview/PreviewMediaPagerAdapter.kt | 7 +- .../client/media/PlayerStateMachineTest.kt | 670 ------------------ 9 files changed, 4 insertions(+), 1549 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/media/Player.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/PlayerError.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/PlayerService.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt delete mode 100644 app/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 30e4988109af..9d11d5f81f91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -386,11 +386,6 @@ android:name=".ui.preview.PreviewImageActivity" android:exported="false" android:theme="@style/Theme.ownCloud.Overlay" /> - - - - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import android.media.AudioManager -import android.media.MediaPlayer -import android.os.PowerManager -import android.widget.MediaController -import com.nextcloud.client.account.User -import com.nextcloud.client.media.PlayerStateMachine.Event -import com.nextcloud.client.media.PlayerStateMachine.State -import com.nextcloud.client.network.ClientFactory -import com.owncloud.android.R -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.common.utils.Log_OC - -@Suppress("TooManyFunctions") -internal class Player( - private val context: Context, - private val clientFactory: ClientFactory, - private val listener: Listener? = null, - audioManager: AudioManager, - private val mediaPlayerCreator: () -> MediaPlayer = { MediaPlayer() } -) : MediaController.MediaPlayerControl { - - private companion object { - const val DEFAULT_VOLUME = 1.0f - const val DUCK_VOLUME = 0.1f - const val MIN_DURATION_ALLOWING_SEEK = 3000 - } - - interface Listener { - fun onRunning(file: OCFile) - fun onStart() - fun onPause() - fun onStop() - fun onError(error: PlayerError) - } - - private var stateMachine: PlayerStateMachine - private var loadUrlTask: LoadUrlTask? = null - - private var enqueuedFile: PlaylistItem? = null - - private var playedFile: OCFile? = null - private var startPositionMs: Long = 0 - private var autoPlay = true - private var user: User? = null - private var dataSource: String? = null - private var lastError: PlayerError? = null - private var mediaPlayer: MediaPlayer? = null - private val focusManager = AudioFocusManager(audioManager, this::onAudioFocusChange) - - private val delegate = object : PlayerStateMachine.Delegate { - override val isDownloaded: Boolean get() = playedFile?.isDown ?: false - override val isAutoplayEnabled: Boolean get() = autoPlay - override val hasEnqueuedFile: Boolean get() = enqueuedFile != null - - override fun onStartRunning() { - trace("onStartRunning()") - enqueuedFile.let { - if (it != null) { - playedFile = it.file - startPositionMs = it.startPositionMs - autoPlay = it.autoPlay - user = it.user - dataSource = if (it.file.isDown) it.file.storagePath else null - listener?.onRunning(it.file) - } else { - throw IllegalStateException("Player started without enqueued file.") - } - } - } - - override fun onStartDownloading() { - trace("onStartDownloading()") - checkNotNull(playedFile) { "File not set." } - checkNotNull(user) - playedFile?.let { - val client = clientFactory.create(user) - val task = LoadUrlTask(client, it.localId, this@Player::onDownloaded) - task.execute() - loadUrlTask = task - } - } - - override fun onPrepare() { - trace("onPrepare()") - mediaPlayer = mediaPlayerCreator.invoke() - mediaPlayer?.setOnErrorListener(this@Player::onMediaPlayerError) - mediaPlayer?.setOnPreparedListener(this@Player::onMediaPlayerPrepared) - mediaPlayer?.setOnCompletionListener(this@Player::onMediaPlayerCompleted) - mediaPlayer?.setOnBufferingUpdateListener(this@Player::onMediaPlayerBufferingUpdate) - mediaPlayer?.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK) - mediaPlayer?.setDataSource(dataSource) - mediaPlayer?.setAudioStreamType(AudioManager.STREAM_MUSIC) - mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) - mediaPlayer?.prepareAsync() - } - - override fun onStopped() { - trace("onStoppped()") - mediaPlayer?.stop() - mediaPlayer?.reset() - mediaPlayer?.release() - mediaPlayer = null - - playedFile = null - startPositionMs = 0 - user = null - autoPlay = true - dataSource = null - loadUrlTask?.cancel(true) - loadUrlTask = null - listener?.onStop() - } - - override fun onError() { - trace("onError()") - this.onStopped() - lastError?.let { - this@Player.listener?.onError(it) - } - if (lastError == null) { - this@Player.listener?.onError(PlayerError("Unknown")) - } - } - - override fun onStartPlayback() { - trace("onStartPlayback()") - mediaPlayer?.start() - listener?.onStart() - } - - override fun onPausePlayback() { - trace("onPausePlayback()") - if (mediaPlayer?.isPlaying == true) { - mediaPlayer?.pause() - listener?.onPause() - } - } - - override fun onRequestFocus() { - trace("onRequestFocus()") - focusManager.requestFocus() - } - - override fun onReleaseFocus() { - trace("onReleaseFocus()") - focusManager.releaseFocus() - } - - override fun onAudioDuck(enabled: Boolean) { - trace("onAudioDuck(): $enabled") - if (enabled) { - mediaPlayer?.setVolume(DUCK_VOLUME, DUCK_VOLUME) - } else { - mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) - } - } - } - - init { - stateMachine = PlayerStateMachine(delegate) - } - - fun play(item: PlaylistItem) { - if (item.file != playedFile) { - stateMachine.post(Event.STOP) - this.enqueuedFile = item - stateMachine.post(Event.PLAY) - } - } - - fun stop() { - stateMachine.post(Event.STOP) - } - - fun stop(file: OCFile) { - if (playedFile == file) { - stateMachine.post(Event.STOP) - } - } - - private fun onMediaPlayerError(mp: MediaPlayer, what: Int, extra: Int): Boolean { - lastError = PlayerError(ErrorFormat.toString(context, what, extra)) - stateMachine.post(Event.ERROR) - return true - } - - private fun onMediaPlayerPrepared(mp: MediaPlayer) { - trace("onMediaPlayerPrepared()") - stateMachine.post(Event.PREPARED) - } - - private fun onMediaPlayerCompleted(mp: MediaPlayer) { - stateMachine.post(Event.STOP) - } - - private fun onMediaPlayerBufferingUpdate(mp: MediaPlayer, percent: Int) { - trace("onMediaPlayerBufferingUpdate(): $percent") - } - - private fun onDownloaded(url: String?) { - if (url != null) { - dataSource = url - stateMachine.post(Event.DOWNLOADED) - } else { - lastError = PlayerError(context.getString(R.string.media_err_io)) - stateMachine.post(Event.ERROR) - } - } - - private fun onAudioFocusChange(focus: AudioFocus) { - when (focus) { - AudioFocus.FOCUS -> stateMachine.post(Event.FOCUS_GAIN) - AudioFocus.DUCK -> stateMachine.post(Event.FOCUS_DUCK) - AudioFocus.LOST -> stateMachine.post(Event.FOCUS_LOST) - } - } - - private fun trace(fmt: String, vararg args: Any?) { - Log_OC.v(javaClass.simpleName, fmt.format(args)) - } - - // region Media player controls - - override fun isPlaying(): Boolean = stateMachine.isInState(State.PLAYING) - - override fun canSeekForward(): Boolean = duration > MIN_DURATION_ALLOWING_SEEK - - override fun canSeekBackward(): Boolean = duration > MIN_DURATION_ALLOWING_SEEK - - override fun getDuration(): Int { - val hasDuration = setOf(State.PLAYING, State.PAUSED) - .find { stateMachine.isInState(it) } != null - return if (hasDuration) { - mediaPlayer?.duration ?: 0 - } else { - 0 - } - } - - override fun pause() { - stateMachine.post(Event.PAUSE) - } - - override fun getBufferPercentage(): Int = 0 - - override fun seekTo(pos: Int) { - if (stateMachine.isInState(State.PLAYING)) { - mediaPlayer?.seekTo(pos) - } - } - - override fun getCurrentPosition(): Int = mediaPlayer?.currentPosition ?: 0 - - override fun start() { - stateMachine.post(Event.PLAY) - } - - override fun getAudioSessionId(): Int = 0 - - override fun canPause(): Boolean = stateMachine.isInState(State.PLAYING) - - // endregion -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerError.kt b/app/src/main/java/com/nextcloud/client/media/PlayerError.kt deleted file mode 100644 index 85a380856527..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerError.kt +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -data class PlayerError(val message: String) diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerService.kt b/app/src/main/java/com/nextcloud/client/media/PlayerService.kt deleted file mode 100644 index 15e6398351c0..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerService.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.media.AudioManager -import android.os.Bundle -import android.os.IBinder -import android.widget.MediaController -import android.widget.Toast -import androidx.core.app.NotificationCompat -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.nextcloud.client.account.User -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.utils.ForegroundServiceHelper -import com.nextcloud.utils.extensions.getParcelableArgument -import com.owncloud.android.R -import com.owncloud.android.datamodel.ForegroundServiceType -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.notifications.NotificationUtils -import com.owncloud.android.ui.preview.PreviewMediaActivity -import com.owncloud.android.utils.theme.ViewThemeUtils -import dagger.android.AndroidInjection -import java.util.Locale -import javax.inject.Inject - -class PlayerService : Service() { - - companion object { - private const val TAG = "PlayerService" - - const val EXTRA_USER = "USER" - const val EXTRA_FILE = "FILE" - const val EXTRA_AUTO_PLAY = "EXTRA_AUTO_PLAY" - const val EXTRA_START_POSITION_MS = "START_POSITION_MS" - const val ACTION_PLAY = "PLAY" - const val ACTION_STOP = "STOP" - const val ACTION_TOGGLE = "TOGGLE" - const val ACTION_STOP_FILE = "STOP_FILE" - - const val IS_MEDIA_CONTROL_LAYOUT_READY = "IS_MEDIA_CONTROL_LAYOUT_READY" - } - - class Binder(val service: PlayerService) : android.os.Binder() { - - /** - * This property returns current instance of media player interface. - * It is not cached and it is suitable for polling. - */ - val player: MediaController.MediaPlayerControl get() = service.player - } - - private val playerListener = object : Player.Listener { - override fun onRunning(file: OCFile) { - Log_OC.d(TAG, "PlayerService.onRunning()") - val intent = Intent(PreviewMediaActivity.MEDIA_CONTROL_READY_RECEIVER).apply { - putExtra(IS_MEDIA_CONTROL_LAYOUT_READY, false) - } - LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) - } - - override fun onStart() { - Log_OC.d(TAG, "PlayerService.onStart()") - val intent = Intent(PreviewMediaActivity.MEDIA_CONTROL_READY_RECEIVER).apply { - putExtra(IS_MEDIA_CONTROL_LAYOUT_READY, true) - } - LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) - } - - override fun onPause() { - Log_OC.d(TAG, "PlayerService.onPause()") - } - - override fun onStop() { - Log_OC.d(TAG, "PlayerService.onStop()") - stopServiceAndRemoveNotification(null) - } - - override fun onError(error: PlayerError) { - Log_OC.d(TAG, "PlayerService.onError()") - Toast.makeText(this@PlayerService, error.message, Toast.LENGTH_SHORT).show() - } - } - - @Inject - lateinit var audioManager: AudioManager - - @Inject - lateinit var clientFactory: ClientFactory - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private lateinit var player: Player - private lateinit var notificationBuilder: NotificationCompat.Builder - private var isRunning = false - - override fun onCreate() { - super.onCreate() - - AndroidInjection.inject(this) - player = Player(applicationContext, clientFactory, playerListener, audioManager) - notificationBuilder = NotificationCompat.Builder(this) - viewThemeUtils.androidx.themeNotificationCompatBuilder(this, notificationBuilder) - - val stop = Intent(this, PlayerService::class.java).apply { - action = ACTION_STOP - } - - val pendingStop = PendingIntent.getService(this, 0, stop, PendingIntent.FLAG_IMMUTABLE) - notificationBuilder.addAction(0, getString(R.string.player_stop).lowercase(Locale.getDefault()), pendingStop) - - val toggle = Intent(this, PlayerService::class.java).apply { - action = ACTION_TOGGLE - } - - val pendingToggle = PendingIntent.getService(this, 0, toggle, PendingIntent.FLAG_IMMUTABLE) - notificationBuilder.addAction( - 0, - getString(R.string.player_toggle).lowercase(Locale.getDefault()), - pendingToggle - ) - } - - override fun onBind(intent: Intent?): IBinder? = Binder(this) - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - Log_OC.d(TAG, "player service started") - if (!isRunning) { - val file = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - if (file != null) { - startForeground(file) - } else { - startForegroundWithPlaceholder() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - return START_NOT_STICKY - } - } - - when (intent.action) { - ACTION_PLAY -> onActionPlay(intent) - ACTION_STOP -> onActionStop() - ACTION_STOP_FILE -> onActionStopFile(intent.extras) - ACTION_TOGGLE -> onActionToggle() - } - return START_NOT_STICKY - } - - private fun startForegroundWithPlaceholder() { - val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) - notificationBuilder.run { - setSmallIcon(R.drawable.ic_play_arrow) - setWhen(System.currentTimeMillis()) - setOngoing(false) - setContentTitle(ticker) - setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) - } - ForegroundServiceHelper.startService( - this, - R.string.media_notif_ticker, - notificationBuilder.build(), - ForegroundServiceType.MediaPlayback - ) - } - - private fun onActionToggle() { - player.run { - if (isPlaying) { - pause() - } else { - start() - } - } - } - - private fun onActionPlay(intent: Intent) { - val user: User = intent.getParcelableArgument(EXTRA_USER, User::class.java)!! - val file: OCFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java)!! - val startPos = intent.getLongExtra(EXTRA_START_POSITION_MS, 0) - val autoPlay = intent.getBooleanExtra(EXTRA_AUTO_PLAY, true) - val item = PlaylistItem(file = file, startPositionMs = startPos, autoPlay = autoPlay, user = user) - player.play(item) - } - - private fun onActionStop() { - stopServiceAndRemoveNotification(null) - } - - private fun onActionStopFile(args: Bundle?) { - val file: OCFile = args?.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - ?: throw IllegalArgumentException("Missing file argument") - stopServiceAndRemoveNotification(file) - } - - private fun startForeground(currentFile: OCFile) { - val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) - val content = getString(R.string.media_state_playing, currentFile.getFileName()) - - notificationBuilder.run { - setSmallIcon(R.drawable.ic_play_arrow) - setWhen(System.currentTimeMillis()) - setOngoing(true) - setContentTitle(ticker) - setContentText(content) - setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) - } - - ForegroundServiceHelper.startService( - this, - R.string.media_notif_ticker, - notificationBuilder.build(), - ForegroundServiceType.MediaPlayback - ) - - isRunning = true - } - - private fun stopServiceAndRemoveNotification(file: OCFile?) { - if (file == null) { - player.stop() - } else { - player.stop(file) - } - - if (isRunning) { - stopForeground(true) - stopSelf() - isRunning = false - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt b/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt deleted file mode 100644 index 2c89ca57dc62..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.widget.MediaController -import androidx.core.content.ContextCompat -import com.nextcloud.client.account.User -import com.owncloud.android.datamodel.OCFile - -@Suppress("TooManyFunctions") // implementing large interface -class PlayerServiceConnection(private val context: Context) : MediaController.MediaPlayerControl { - - var isConnected: Boolean = false - private set - - private var binder: PlayerService.Binder? = null - - fun bind() { - val intent = Intent(context, PlayerService::class.java) - context.bindService(intent, connection, Context.BIND_AUTO_CREATE) - } - - fun unbind() { - if (isConnected) { - binder = null - isConnected = false - context.unbindService(connection) - } - } - - fun start(user: User, file: OCFile, playImmediately: Boolean, position: Long) { - val i = Intent(context, PlayerService::class.java).apply { - putExtra(PlayerService.EXTRA_USER, user) - putExtra(PlayerService.EXTRA_FILE, file) - putExtra(PlayerService.EXTRA_AUTO_PLAY, playImmediately) - putExtra(PlayerService.EXTRA_START_POSITION_MS, position) - action = PlayerService.ACTION_PLAY - } - - startForegroundService(i) - } - - fun stop(file: OCFile) { - val i = Intent(context, PlayerService::class.java) - i.putExtra(PlayerService.EXTRA_FILE, file) - i.action = PlayerService.ACTION_STOP_FILE - try { - context.startService(i) - } catch (ex: IllegalStateException) { - // https://developer.android.com/about/versions/oreo/android-8.0-changes#back-all - // ignore it - the service is not running and does not need to be stopped - } - } - - fun stop() { - val i = Intent(context, PlayerService::class.java) - i.action = PlayerService.ACTION_STOP - try { - context.startService(i) - } catch (ex: IllegalStateException) { - // https://developer.android.com/about/versions/oreo/android-8.0-changes#back-all - // ignore it - the service is not running and does not need to be stopped - } - } - - private val connection = object : ServiceConnection { - override fun onServiceDisconnected(name: ComponentName?) { - isConnected = false - binder = null - } - - override fun onServiceConnected(name: ComponentName?, localBinder: IBinder?) { - binder = localBinder as PlayerService.Binder - isConnected = true - } - } - - // region Media controller - - override fun isPlaying(): Boolean = binder?.player?.isPlaying ?: false - - override fun canSeekForward(): Boolean = binder?.player?.canSeekForward() ?: false - - override fun getDuration(): Int = binder?.player?.duration ?: 0 - - override fun pause() { - binder?.player?.pause() - } - - override fun getBufferPercentage(): Int = binder?.player?.bufferPercentage ?: 0 - - override fun seekTo(pos: Int) { - binder?.player?.seekTo(pos) - } - - override fun getCurrentPosition(): Int = binder?.player?.currentPosition ?: 0 - - override fun canSeekBackward(): Boolean = binder?.player?.canSeekBackward() ?: false - - override fun start() { - binder?.player?.start() - } - - override fun getAudioSessionId(): Int = 0 - - override fun canPause(): Boolean = binder?.player?.canPause() ?: false - - // endregion - - private fun startForegroundService(i: Intent) { - ContextCompat.startForegroundService(context, i) - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt b/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt deleted file mode 100644 index 3310244ab455..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import com.github.oxo42.stateless4j.StateMachine -import com.github.oxo42.stateless4j.StateMachineConfig -import com.github.oxo42.stateless4j.delegates.Action -import com.github.oxo42.stateless4j.transitions.Transition -import java.util.ArrayDeque - -/** - * To see visual representation of the state machine, install PlanUml plugin. - * http://plantuml.com/ - * - * @startuml - * - * note "> - entry action\n< - exit action\n[exp] - transition guard\nfunction() - transition action" as README - * - * [*] --> STOPPED - * STOPPED --> RUNNING: PLAY\n[hasEnqueuedFile] - * RUNNING --> STOPPED: STOP\nonStop - * RUNNING --> STOPPED: ERROR\nonError - * RUNNING: >onStartRunning - * - * state RUNNING { - * [*] --> DOWNLOADING: [!isDownloaded] - * [*] --> PREPARING: [isDownloaded] - * DOWNLOADING: >onStartDownloading - * DOWNLOADING --> PREPARING: DOWNLOADED - * - * PREPARING: >onPrepare - * PREPARING --> PLAYING: PREPARED\n[autoPlay] - * PREPARING --> PAUSED: PREPARED\n[!autoPlay] - * PLAYING --> PAUSED: PAUSE\nFOCUS_LOST - * - * PAUSED: >onPausePlayback - * PAUSED --> PLAYING: PLAY - * - * PLAYING: >onRequestFocus - * PLAYING: AWAIT_FOCUS - * AWAIT_FOCUS --> FOCUSED: FOCUS_GAIN\nonStartPlayback() - * FOCUSED -l-> DUCKED: FOCUS_DUCK - * DUCKED: >onAudioDuck(true)\n FOCUSED: FOCUS_GAIN - * } - * } - * - * @enduml - */ -internal class PlayerStateMachine(initialState: State, private val delegate: Delegate) { - - constructor(delegate: Delegate) : this(State.STOPPED, delegate) - - interface Delegate { - val isDownloaded: Boolean - val isAutoplayEnabled: Boolean - val hasEnqueuedFile: Boolean - - fun onStartRunning() - fun onStartDownloading() - fun onPrepare() - fun onStopped() - fun onError() - fun onStartPlayback() - fun onPausePlayback() - fun onRequestFocus() - fun onReleaseFocus() - fun onAudioDuck(enabled: Boolean) - } - - enum class State { - STOPPED, - RUNNING, - RUNNING_INITIAL, - DOWNLOADING, - PREPARING, - PAUSED, - PLAYING, - AWAIT_FOCUS, - FOCUSED, - DUCKED - } - - enum class Event { - PLAY, - DOWNLOADED, - PREPARED, - STOP, - PAUSE, - ERROR, - FOCUS_LOST, - FOCUS_GAIN, - FOCUS_DUCK, - IMMEDIATE_TRANSITION - } - - private var pendingEvents = ArrayDeque() - private var isProcessing = false - private val stateMachine: StateMachine - - /** - * Immediate state machine state. This attribute provides innermost active state. - * For checking parent states, use [PlayerStateMachine.isInState]. - */ - val state: State - get() { - return stateMachine.state - } - - init { - val config = StateMachineConfig() - - config.configure(State.STOPPED) - .permitIf(Event.PLAY, State.RUNNING_INITIAL) { delegate.hasEnqueuedFile } - .onEntryFrom(Event.STOP, delegate::onStopped) - .onEntryFrom(Event.ERROR, delegate::onError) - - config.configure(State.RUNNING) - .permit(Event.STOP, State.STOPPED) - .permit(Event.ERROR, State.STOPPED) - .onEntry(delegate::onStartRunning) - - config.configure(State.RUNNING_INITIAL) - .substateOf(State.RUNNING) - .permitIf(Event.IMMEDIATE_TRANSITION, State.DOWNLOADING, { !delegate.isDownloaded }) - .permitIf(Event.IMMEDIATE_TRANSITION, State.PREPARING, { delegate.isDownloaded }) - .onEntry(this::immediateTransition) - - config.configure(State.DOWNLOADING) - .substateOf(State.RUNNING) - .permit(Event.DOWNLOADED, State.PREPARING) - .onEntry(delegate::onStartDownloading) - - config.configure(State.PREPARING) - .substateOf(State.RUNNING) - .permitIf(Event.PREPARED, State.AWAIT_FOCUS) { delegate.isAutoplayEnabled } - .permitIf(Event.PREPARED, State.PAUSED) { !delegate.isAutoplayEnabled } - .onEntry(delegate::onPrepare) - - config.configure(State.PLAYING) - .substateOf(State.RUNNING) - .permit(Event.PAUSE, State.PAUSED) - .permit(Event.FOCUS_LOST, State.PAUSED) - .onEntry(delegate::onRequestFocus) - .onExit(delegate::onReleaseFocus) - - config.configure(State.PAUSED) - .substateOf(State.RUNNING) - .permit(Event.PLAY, State.AWAIT_FOCUS) - .onEntry(delegate::onPausePlayback) - - config.configure(State.AWAIT_FOCUS) - .substateOf(State.PLAYING) - .permit(Event.FOCUS_GAIN, State.FOCUSED) - - config.configure(State.FOCUSED) - .substateOf(State.PLAYING) - .permit(Event.FOCUS_DUCK, State.DUCKED) - .onEntry(this::onAudioFocusGain) - - config.configure(State.DUCKED) - .substateOf(State.PLAYING) - .permit(Event.FOCUS_GAIN, State.FOCUSED) - .onEntry(Action { delegate.onAudioDuck(true) }) - .onExit(Action { delegate.onAudioDuck(false) }) - - stateMachine = StateMachine(initialState, config) - stateMachine.onUnhandledTrigger { _, _ -> - /* ignore unhandled event */ - } - } - - private fun immediateTransition() { - stateMachine.fire(Event.IMMEDIATE_TRANSITION) - } - - private fun onAudioFocusGain(t: Transition) { - if (t.source == State.AWAIT_FOCUS) { - delegate.onStartPlayback() - } - } - - /** - * Check if state machine is in a given state. - * Contrary to [PlayerStateMachine.state] attribute, this method checks for - * parent states. - */ - fun isInState(state: State): Boolean = stateMachine.isInState(state) - - /** - * Post state machine event to internal queue. - * - * This design ensures that we're not triggering multiple events - * from state machines callbacks before the transition is fully - * completed. - * - * Method is re-entrant. - */ - fun post(event: Event) { - pendingEvents.addLast(event) - if (!isProcessing) { - isProcessing = true - while (pendingEvents.isNotEmpty()) { - val processedEvent = pendingEvents.removeFirst() - stateMachine.fire(processedEvent) - } - isProcessing = false - } - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index b93b67bc64bc..919f1e98d24f 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -193,10 +193,11 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { fun getFilePosition(file: OCFile): Int = imageFiles.indexOf(file) fun updateFile(position: Int, file: OCFile) { - val fragmentToUpdate = mCachedFragments[position] - if (fragmentToUpdate != null) { - mObsoleteFragments.add(fragmentToUpdate) + if (position < 0 || position >= imageFiles.size) { + return } + + mCachedFragments[position]?.let { mObsoleteFragments.add(it) } mObsoletePositions.add(position) imageFiles[position] = file } diff --git a/app/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt b/app/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt deleted file mode 100644 index 5d7ac669d761..000000000000 --- a/app/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt +++ /dev/null @@ -1,670 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import com.nextcloud.client.media.PlayerStateMachine.Event -import com.nextcloud.client.media.PlayerStateMachine.State -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Suite -import org.mockito.Mock -import org.mockito.MockitoAnnotations -import org.mockito.kotlin.eq -import org.mockito.kotlin.inOrder -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -@RunWith(Suite::class) -@Suite.SuiteClasses( - PlayerStateMachineTest.Constructor::class, - PlayerStateMachineTest.EventHandling::class, - PlayerStateMachineTest.Stopped::class, - PlayerStateMachineTest.Downloading::class, - PlayerStateMachineTest.Preparing::class, - PlayerStateMachineTest.AwaitFocus::class, - PlayerStateMachineTest.Focused::class, - PlayerStateMachineTest.Ducked::class, - PlayerStateMachineTest.Paused::class -) -internal class PlayerStateMachineTest { - - abstract class Base { - @Mock - protected lateinit var delegate: PlayerStateMachine.Delegate - protected lateinit var fsm: PlayerStateMachine - - fun setUp(initialState: State) { - MockitoAnnotations.initMocks(this) - fsm = PlayerStateMachine(initialState, delegate) - } - } - - class Constructor { - - private val delegate: PlayerStateMachine.Delegate = mock() - - @Test - fun `default state is stopped`() { - val fsm = PlayerStateMachine(delegate) - assertEquals(State.STOPPED, fsm.state) - } - - @Test - fun `inital state can be set`() { - val fsm = PlayerStateMachine(State.PREPARING, delegate) - assertEquals(State.PREPARING, fsm.state) - } - } - - class EventHandling : Base() { - - @Before - fun setUp() { - super.setUp(State.STOPPED) - } - - @Test - fun `can post multiple events from callback`() { - whenever(delegate.isDownloaded).thenReturn(false) - whenever(delegate.isAutoplayEnabled).thenReturn(false) - whenever(delegate.hasEnqueuedFile).thenReturn(true) - whenever(delegate.onStartDownloading()).thenAnswer { - fsm.post(Event.DOWNLOADED) - fsm.post(Event.PREPARED) - } - - // WHEN - // an event is posted from a state machine callback - fsm.post(Event.PLAY) // posts error() in callback - - // THEN - // enqueued events is handled triggering transitions - assertEquals(State.PAUSED, fsm.state) - verify(delegate).onStartRunning() - verify(delegate).onStartDownloading() - verify(delegate).onPrepare() - verify(delegate).onPausePlayback() - } - - @Test - fun `unhandled events are ignored`() { - // GIVEN - // state machine is in STOPPED state - // PAUSE event is not handled in this staet - - // WHEN - // state machine receives unhandled PAUSE event - fsm.post(Event.PAUSE) - - // THEN - // event is ignored - // exception is not thrown - } - } - - class Stopped : Base() { - - @Before - fun setUp() { - super.setUp(State.STOPPED) - } - - @Test - fun `initiall state is stopped`() { - assertEquals(State.STOPPED, fsm.state) - } - - @Test - fun `playing requires enqueued file`() { - // GIVEN - // no file is enqueued - whenever(delegate.hasEnqueuedFile).thenReturn(false) - - // WHEN - // play is triggered - fsm.post(Event.PLAY) - - // THEN - // remains in stopped state - assertEquals(State.STOPPED, fsm.state) - } - - @Test - fun `playing remote media triggers downloading`() { - // GIVEN - // file is enqueued - // media is not downloaded - whenever(delegate.hasEnqueuedFile).thenReturn(true) - whenever(delegate.isDownloaded).thenReturn(false) - - // WHEN - // play is requested - fsm.post(Event.PLAY) - - // THEN - // enqueued file is loaded - // media stream download starts - assertEquals(State.DOWNLOADING, fsm.state) - verify(delegate).onStartRunning() - verify(delegate).onStartDownloading() - } - - @Test - fun `playing local media triggers player preparation`() { - // GIVEN - // file is enqueued - // media is downloaded - whenever(delegate.hasEnqueuedFile).thenReturn(true) - whenever(delegate.isDownloaded).thenReturn(true) - - // WHEN - // play is requested - fsm.post(Event.PLAY) - - // THEN - // player preparation starts - assertEquals(State.PREPARING, fsm.state) - verify(delegate).onPrepare() - } - } - - class Downloading : Base() { - - // GIVEN - // player is downloading stream URL - @Before - fun setUp() { - setUp(State.DOWNLOADING) - } - - @Test - fun `stream url download is successfull`() { - // WHEN - // stream url downloaded - fsm.post(Event.DOWNLOADED) - - // THEN - // player is preparing - assertEquals(State.PREPARING, fsm.state) - verify(delegate).onPrepare() - } - - @Test - fun `stream url download failed`() { - // WHEN - // download error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - - @Test - fun `player stopped`() { - // WHEN - // download error - fsm.post(Event.STOP) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onStopped() - } - - @Test - fun `player error`() { - // WHEN - // player error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // error handler is called - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - } - - class Preparing : Base() { - - @Before - fun setUp() { - setUp(State.PREPARING) - } - - @Test - fun `start in autoplay mode`() { - // GIVEN - // media player is preparing - // autoplay is enabled - whenever(delegate.isAutoplayEnabled).thenReturn(true) - - // WHEN - // media player is ready - fsm.post(Event.PREPARED) - - // THEN - // start playing - // request audio focus - // awaiting focus - assertEquals(State.AWAIT_FOCUS, fsm.state) - verify(delegate).onRequestFocus() - } - - @Test - fun `start in paused mode`() { - // GIVEN - // media player is preparing - // autoplay is disabled - whenever(delegate.isAutoplayEnabled).thenReturn(false) - - // WHEN - // media player is ready - fsm.post(Event.PREPARED) - - // THEN - // media player is not started - assertEquals(State.PAUSED, fsm.state) - verify(delegate, never()).onStartPlayback() - } - - @Test - fun `player is stopped during preparation`() { - // GIVEN - // media player is preparing - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onStopped() - } - - @Test - fun `error during preparation`() { - // GIVEN - // media player is preparing - // WHEN - // download error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // error callback is invoked - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - } - - class AwaitFocus : Base() { - - @Before - fun setUp() { - setUp(State.AWAIT_FOCUS) - } - - @Test - fun pause() { - // GIVEN - // media player is awaiting focus - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // media player enters paused state - // focus is released - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `audio focus denied`() { - // GIVEN - // media player is awaiting focus - // WHEN - // audio focus was denied - fsm.post(Event.FOCUS_LOST) - - // THEN - // media player enters paused state - assertEquals(State.PAUSED, fsm.state) - verify(delegate).onPausePlayback() - } - - @Test - fun `audio focus granted`() { - // GIVEN - // media player is awaiting focus - // WHEN - // audio focus was granted - fsm.post(Event.FOCUS_GAIN) - - // THEN - // media player enters focused state - // playback is started - assertEquals(State.FOCUSED, fsm.state) - verify(delegate).onStartPlayback() - } - - @Test - fun stop() { - // GIVEN - // media player is awaiting focus - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - // focus is released - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onStopped() - } - } - - @Test - fun error() { - // GIVEN - // media player is playing - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // focus is released - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onError() - } - } - } - - class Focused : Base() { - - @Before - fun setUp() { - setUp(State.FOCUSED) - } - - @Test - fun pause() { - // GIVEN - // media player is awaiting focus - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // media player enters paused state - // focus is released - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `lost focus`() { - // GIVEN - // media player is awaiting focus - // WHEN - // media player lost audio focus - fsm.post(Event.FOCUS_LOST) - - // THEN - // media player enters paused state - // focus is released - assertEquals(State.PAUSED, fsm.state) - verify(delegate).onPausePlayback() - } - - @Test - fun `audio focus duck`() { - // GIVEN - // media player is playing - // WHEN - // media player focus duck is requested - fsm.post(Event.FOCUS_DUCK) - - // THEN - // media player ducks - assertEquals(State.DUCKED, fsm.state) - verify(delegate).onAudioDuck(eq(true)) - } - - @Test - fun stop() { - // GIVEN - // media player is awaiting focus - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - // focus is released - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onStopped() - } - } - - @Test - fun error() { - // GIVEN - // media player is playing - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // focus is released - // error is signaled - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onError() - } - } - } - - class Ducked : Base() { - - @Before - fun setUp() { - setUp(State.DUCKED) - } - - @Test - fun pause() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is paused - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `lost focus`() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player is looses focus - fsm.post(Event.FOCUS_LOST) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is paused - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is paused - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `audio focus is re-gained`() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player focus duck is requested - fsm.post(Event.FOCUS_GAIN) - - // THEN - // media player is focused - // audio focus duck is disabled - // playback is not restarted - assertEquals(State.FOCUSED, fsm.state) - verify(delegate).onAudioDuck(eq(false)) - verify(delegate, never()).onStartPlayback() - } - - @Test - fun stop() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player is stopped - fsm.post(Event.STOP) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is stopped - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onStopped() - } - } - - @Test - fun error() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is stopped - // error is signaled - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onError() - } - } - } - - class Paused : Base() { - - @Before - fun setUp() { - setUp(State.PAUSED) - } - - @Test - fun pause() { - // GIVEN - // media player is paused - // WHEN - // media player is resumed - fsm.post(Event.PLAY) - - // THEN - // media player enters playing state - // audio focus is requsted - assertEquals(State.AWAIT_FOCUS, fsm.state) - verify(delegate).onRequestFocus() - } - - @Test - fun stop() { - // GIVEN - // media player is playing - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onStopped() - } - - @Test - fun error() { - // GIVEN - // media player is playing - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // error callback is invoked - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - } -} From 2792cb409bfd9dc5d6765a7d588535c82bf0230b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:55:12 +0100 Subject: [PATCH 07/69] remove unused activity Signed-off-by: alperozturk96 --- .../client/player/media3/Media3PlaybackModel.kt | 10 ++++++++++ .../com/nextcloud/client/player/model/PlaybackModel.kt | 3 +++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt index 565c5a69ff9e..1756579ebed1 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt @@ -29,6 +29,7 @@ import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.RepeatMode import com.nextcloud.client.player.util.PeriodicAction +import com.owncloud.android.datamodel.OCFile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -209,6 +210,15 @@ class Media3PlaybackModel @Inject constructor( } } + override fun stopPlaying(file: OCFile) { + controller?.run { + val mediaItemIndex = indexOfFirst { it.mediaId == file.localId.toString() } + if (mediaItemIndex >= 0) { + release() + } + } + } + private fun onPlaybackUpdate() { state.ifPresent(modelCompositeListener::onPlaybackUpdate) } diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt index 764a4d499906..eaeb5ab2c87f 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt @@ -12,6 +12,7 @@ import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.RepeatMode +import com.owncloud.android.datamodel.OCFile import kotlinx.coroutines.flow.Flow import java.util.Optional @@ -50,6 +51,8 @@ interface PlaybackModel { fun switchToFile(file: PlaybackFile) + fun stopPlaying(file: OCFile) + interface Listener { fun onPlaybackUpdate(state: PlaybackState) From 6606fb38670c0e262981012590b1b6d632c82e44 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 09:00:26 +0100 Subject: [PATCH 08/69] use m3 icon buttons and add content description Signed-off-by: alperozturk96 --- .../client/player/ui/PlayerActivity.kt | 15 ++-------- .../player/ui/control/PlayerControlView.kt | 21 +++++++++++--- .../player/ui/video/VideoFileFragment.kt | 4 ++- .../android/ui/activity/DrawerActivity.java | 2 +- app/src/main/res/drawable/player_ic_pause.xml | 28 ++++++++----------- app/src/main/res/drawable/player_ic_play.xml | 26 ++++++----------- .../main/res/layout/player_control_view.xml | 26 ++++++++++------- 7 files changed, 60 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 5dd9c38ffd2f..b65a540dc152 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -13,7 +13,6 @@ import android.content.Intent import android.content.res.Configuration import android.graphics.Rect import android.media.AudioManager -import android.os.Build import android.os.Bundle import android.util.Rational import android.view.View @@ -26,13 +25,13 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.nextcloud.client.di.Injectable -import com.nextcloud.client.player.model.PlaybackModel import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.ui.audio.AudioPlayerView import com.nextcloud.client.player.ui.video.VideoPlayerView import com.nextcloud.client.player.util.isPictureInPictureAllowed import com.nextcloud.ui.fileactions.FileAction import com.nextcloud.ui.fileactions.FileActionsBottomSheet +import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.activity.FileActivity @@ -61,9 +60,6 @@ class PlayerActivity : } } - @Inject - lateinit var playbackModel: PlaybackModel - @Inject lateinit var viewModelFactory: PlayerViewModel.Factory @@ -123,14 +119,9 @@ class PlayerActivity : playerView.onStart() } - @Suppress("DEPRECATION") private fun Intent.getPlaybackFileType(): PlaybackFileType { - val playbackFileType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getSerializableExtra(PLAYBACK_FILE_TYPE, PlaybackFileType::class.java) - } else { - getSerializableExtra(PLAYBACK_FILE_TYPE) as PlaybackFileType? - } - return playbackFileType ?: throw IllegalStateException("Playback file type was not defined") + return getSerializableArgument(PLAYBACK_FILE_TYPE, PlaybackFileType::class.java) + ?: throw IllegalStateException("Playback file type was not defined") } override fun onStart() { diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index 11dc156d64e8..e3ace86ad61b 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -14,6 +14,8 @@ import android.view.View import android.widget.LinearLayout import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.flowWithLifecycle @@ -22,7 +24,6 @@ import com.nextcloud.client.player.model.state.PlaybackItemState import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.PlayerState import com.nextcloud.client.player.model.state.RepeatMode -import com.nextcloud.client.player.util.setTint import com.owncloud.android.R import com.owncloud.android.databinding.PlayerControlViewBinding import dagger.android.HasAndroidInjector @@ -187,17 +188,29 @@ class PlayerControlView @JvmOverloads constructor( } private fun renderRepeatButton(repeatSingle: Boolean) { - binding.ivRepeat.setTint(if (repeatSingle) R.color.player_accent_color else R.color.player_default_icon_color) + binding.ivRepeat.iconTint = ContextCompat.getColorStateList( + binding.root.context, + if (repeatSingle) R.color.player_accent_color + else R.color.player_default_icon_color + ) binding.ivRepeat.tag = if (repeatSingle) TAG_CLICK_COMMAND_DO_NOT_REPEAT else TAG_CLICK_COMMAND_REPEAT } private fun renderShuffleButton(shuffle: Boolean) { - binding.ivRandom.setTint(if (shuffle) R.color.player_accent_color else R.color.player_default_icon_color) + binding.ivRandom.iconTint = ContextCompat.getColorStateList( + binding.root.context, + if (shuffle) R.color.player_accent_color + else R.color.player_default_icon_color + ) binding.ivRandom.tag = if (shuffle) TAG_CLICK_COMMAND_DO_NOT_SHUFFLE else TAG_CLICK_COMMAND_SHUFFLE } private fun renderPlayPauseButton(isPlaying: Boolean) { - binding.ivPlayPause.setImageResource(if (isPlaying) R.drawable.player_ic_pause else R.drawable.player_ic_play) + binding.ivPlayPause.icon = AppCompatResources.getDrawable( + binding.root.context, + if (isPlaying) R.drawable.player_ic_pause + else R.drawable.player_ic_play + ) binding.ivPlayPause.tag = if (isPlaying) TAG_CLICK_COMMAND_PAUSE else TAG_CLICK_COMMAND_PLAY } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index 9e339326efdd..0cbbba4c02d7 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -21,6 +21,7 @@ import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.VideoSize import com.nextcloud.client.player.util.getDisplayHeight import com.nextcloud.client.player.util.getDisplayWidth +import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding import dagger.android.support.AndroidSupportInjection @@ -55,7 +56,8 @@ class VideoFileFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) AndroidSupportInjection.inject(this) - this.file = arguments?.getSerializable(ARGUMENT_FILE) as PlaybackFile + val playbackFile = arguments.getSerializableArgument(ARGUMENT_FILE, PlaybackFile::class.java) + this.file = playbackFile ?: throw IllegalArgumentException("bundle is not containing playback file") } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 1e5351352efc..fb6d4f8366c7 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -154,7 +154,7 @@ public abstract class DrawerActivity extends ToolbarActivity public static final int REQ_MEDIA_ACCESS = 3000; @Inject - PlaybackModel playbackModel; + protected PlaybackModel playbackModel; /** * Reference to the drawer layout. diff --git a/app/src/main/res/drawable/player_ic_pause.xml b/app/src/main/res/drawable/player_ic_pause.xml index 48f169b40fd1..c756bb9e0f11 100644 --- a/app/src/main/res/drawable/player_ic_pause.xml +++ b/app/src/main/res/drawable/player_ic_pause.xml @@ -1,23 +1,17 @@ - + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + - - - + android:fillColor="@android:color/white" + android:pathData="M360,640L440,640L440,320L360,320L360,640ZM520,640L600,640L600,320L520,320L520,640ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" /> + diff --git a/app/src/main/res/drawable/player_ic_play.xml b/app/src/main/res/drawable/player_ic_play.xml index 0b3fe31771c8..694647bb8222 100644 --- a/app/src/main/res/drawable/player_ic_play.xml +++ b/app/src/main/res/drawable/player_ic_play.xml @@ -1,23 +1,15 @@ - + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> - - - + android:fillColor="#e3e3e3" + android:pathData="m380,660 l280,-180 -280,-180v360ZM480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q83,0 156,31.5T763,197q54,54 85.5,127T880,480q0,83 -31.5,156T763,763q-54,54 -127,85.5T480,880ZM480,800q134,0 227,-93t93,-227q0,-134 -93,-227t-227,-93q-134,0 -227,93t-93,227q0,134 93,227t227,93ZM480,480Z" /> diff --git a/app/src/main/res/layout/player_control_view.xml b/app/src/main/res/layout/player_control_view.xml index 772b50e22fd0..70afd3c22256 100644 --- a/app/src/main/res/layout/player_control_view.xml +++ b/app/src/main/res/layout/player_control_view.xml @@ -8,6 +8,7 @@ @@ -50,30 +51,35 @@ android:gravity="center" android:orientation="horizontal"> - + app:icon="@drawable/player_ic_repeat" /> - + app:icon="@drawable/player_ic_skip_previous" /> - + app:icon="@drawable/player_ic_play" /> - + app:icon="@drawable/player_ic_skip_next" /> - + app:icon="@drawable/player_ic_shuffle" /> From e9e9e56841c4d363675892a5bcc2a5e6cd6c61ca Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 12:07:35 +0100 Subject: [PATCH 09/69] fix codacy Signed-off-by: alperozturk96 --- .../client/player/ui/PlayerActivity.kt | 11 ++++++---- .../player/ui/control/PlayerControlView.kt | 21 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index b65a540dc152..029636b69b35 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -13,6 +13,7 @@ import android.content.Intent import android.content.res.Configuration import android.graphics.Rect import android.media.AudioManager +import android.os.Build import android.os.Bundle import android.util.Rational import android.view.View @@ -119,10 +120,9 @@ class PlayerActivity : playerView.onStart() } - private fun Intent.getPlaybackFileType(): PlaybackFileType { - return getSerializableArgument(PLAYBACK_FILE_TYPE, PlaybackFileType::class.java) + private fun Intent.getPlaybackFileType(): PlaybackFileType = + getSerializableArgument(PLAYBACK_FILE_TYPE, PlaybackFileType::class.java) ?: throw IllegalStateException("Playback file type was not defined") - } override fun onStart() { super.onStart() @@ -175,11 +175,14 @@ class PlayerActivity : private fun createPictureInPictureParams(): PictureInPictureParams = PictureInPictureParams.Builder().let { it.setAspectRatio(pipAspectRatio) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + it.setAutoEnterEnabled(true) + } getSourceRectHint().let(it::setSourceRectHint) it.build() } - private fun getSourceRectHint(): Rect? { + private fun getSourceRectHint(): Rect { val containerRect = Rect() playerView.getGlobalVisibleRect(containerRect) val sourceHeightHint = (containerRect.width() / pipAspectRatio.toFloat()).toInt() diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index e3ace86ad61b..18ce545c02da 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -190,8 +190,11 @@ class PlayerControlView @JvmOverloads constructor( private fun renderRepeatButton(repeatSingle: Boolean) { binding.ivRepeat.iconTint = ContextCompat.getColorStateList( binding.root.context, - if (repeatSingle) R.color.player_accent_color - else R.color.player_default_icon_color + if (repeatSingle) { + R.color.player_accent_color + } else { + R.color.player_default_icon_color + } ) binding.ivRepeat.tag = if (repeatSingle) TAG_CLICK_COMMAND_DO_NOT_REPEAT else TAG_CLICK_COMMAND_REPEAT } @@ -199,8 +202,11 @@ class PlayerControlView @JvmOverloads constructor( private fun renderShuffleButton(shuffle: Boolean) { binding.ivRandom.iconTint = ContextCompat.getColorStateList( binding.root.context, - if (shuffle) R.color.player_accent_color - else R.color.player_default_icon_color + if (shuffle) { + R.color.player_accent_color + } else { + R.color.player_default_icon_color + } ) binding.ivRandom.tag = if (shuffle) TAG_CLICK_COMMAND_DO_NOT_SHUFFLE else TAG_CLICK_COMMAND_SHUFFLE } @@ -208,8 +214,11 @@ class PlayerControlView @JvmOverloads constructor( private fun renderPlayPauseButton(isPlaying: Boolean) { binding.ivPlayPause.icon = AppCompatResources.getDrawable( binding.root.context, - if (isPlaying) R.drawable.player_ic_pause - else R.drawable.player_ic_play + if (isPlaying) { + R.drawable.player_ic_pause + } else { + R.drawable.player_ic_play + } ) binding.ivPlayPause.tag = if (isPlaying) TAG_CLICK_COMMAND_PAUSE else TAG_CLICK_COMMAND_PLAY } From f873a1fe65ef25e2a5eae087a2cd7210c7ada224 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 12:20:50 +0100 Subject: [PATCH 10/69] fix action bar title after playing music Signed-off-by: alperozturk96 --- .../java/com/nextcloud/client/player/ui/PlayerActivity.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 029636b69b35..91a8f10efd9a 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -87,10 +87,14 @@ class PlayerActivity : .onEach { handleEvent(it) } .launchIn(lifecycleScope) - if (isPictureInPictureAllowed()) { + onBackPressedCallback = onBackPressedDispatcher.addCallback(this) { val isVideoPlayback = playbackFileType == PlaybackFileType.VIDEO - onBackPressedCallback = onBackPressedDispatcher.addCallback(this, enabled = isVideoPlayback) { + + if (isPictureInPictureAllowed() && isVideoPlayback) { switchToPictureInPictureMode() + } else { + file = file?.parentId?.let { storageManager.getFileById(it) } + finish() } } From 5a3e682cc64e459dd64262fb3581c1471c8a1c37 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 14:41:47 +0200 Subject: [PATCH 11/69] wip Signed-off-by: alperozturk96 --- .../com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt index cd80aa84a0b7..9166d438168b 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt @@ -122,6 +122,7 @@ object OCShareToOCFileConverter { note = firstShare.note fileId = firstShare.fileSource remoteId = firstShare.remoteId.toString() + localId = firstShare.fileSource this.firstShareTimestamp = firstShareTimestamp isFavorite = firstShare.isFavorite } From 2a9f17492a8e9390c2330763aa36f0eec23c8484 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:33:20 +0200 Subject: [PATCH 12/69] onPlaybackError Signed-off-by: alperozturk96 --- .../nextcloud/client/player/ui/PlayerView.kt | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index 7aee591f7bce..dd1121ab1a3f 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -15,6 +15,8 @@ import android.widget.TextView import androidx.annotation.CallSuper import androidx.annotation.LayoutRes import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.player.model.PlaybackModel import com.nextcloud.client.player.model.error.SourceException import com.nextcloud.client.player.model.file.PlaybackFile @@ -25,20 +27,33 @@ import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory import com.nextcloud.client.player.ui.pager.PlayerPagerMode import com.nextcloud.client.player.util.WindowWrapper import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.jvm.optionals.getOrNull abstract class PlayerView @JvmOverloads constructor( - context: Context, + private val context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr), PlaybackModel.Listener { + companion object { + private const val TAG = "PlayerView" + } + @Inject lateinit var playbackModel: PlaybackModel + @Inject + lateinit var userAccountManager: UserAccountManager + @get:LayoutRes protected abstract val layoutRes: Int @@ -89,6 +104,32 @@ abstract class PlayerView @JvmOverloads constructor( override fun onPlaybackError(error: Throwable) { if (error is SourceException) { + // TODO: - REMOVE OR HANDLE BETTER + val currentFile = playbackModel.state.getOrNull()?.currentItemState?.file + Log_OC.d(TAG, "current file, id: " + currentFile?.id + " _ name: " + currentFile?.name) + + playerPager.getItems().forEach { + Log_OC.d(TAG, "pager file, id: " + it.id + " _ name: " + it.name) + } + + playbackModel.state.getOrNull()?.currentFiles?.forEach { + Log_OC.d(TAG, "files, id: " + it.id + " _ name: " + it.name) + } + + val storageManager = FileDataStorageManager(userAccountManager.user, context.contentResolver) + val file = currentFile?.id?.toLong()?.let { storageManager.getFileByLocalId(it) } + Log_OC.d(TAG, "oc_file: " + file?.decryptedRemotePath) + + activity.lifecycleScope.launch(Dispatchers.IO) { + val operation = ReadFileRemoteOperation(file?.decryptedRemotePath) + val result = operation.execute(userAccountManager.user, context) + if (result.isSuccess) { + val remoteFile = result.data[0] as RemoteFile + Log_OC.d(TAG, "file is successfully read::: " + remoteFile.remotePath) + } else { + Log_OC.e(TAG, "cannot read file") + } + } DisplayUtils.showSnackMessage(this, R.string.player_error_source_not_found) } else { DisplayUtils.showSnackMessage(this, R.string.common_error_unknown) From c13258371ed2c06e494d41385a40b444dfd9cb63 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:42:03 +0200 Subject: [PATCH 13/69] Rename .java to .kt Signed-off-by: alperozturk96 --- ...{StreamMediaFileOperation.java => StreamMediaFileOperation.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/owncloud/android/files/{StreamMediaFileOperation.java => StreamMediaFileOperation.kt} (100%) diff --git a/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt similarity index 100% rename from app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java rename to app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt From 987383402466f3ce374cd6738d3415eed0d5fba0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:42:03 +0200 Subject: [PATCH 14/69] handle stream media operation better Signed-off-by: alperozturk96 --- .../datasource/DefaultDataSourceTest.kt | 4 +- .../com/nextcloud/client/media/LoadUrlTask.kt | 34 ------ .../media3/datasource/DefaultDataSource.kt | 4 +- .../android/files/StreamMediaFileOperation.kt | 103 ++++++++---------- .../ui/helpers/FileOperationsHelper.java | 2 +- .../ui/preview/PreviewMediaFragment.kt | 2 +- 6 files changed, 53 insertions(+), 96 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt index 5204f7e56eb2..b82fb1a85a72 100644 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt +++ b/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt @@ -85,7 +85,7 @@ class DefaultDataSourceTest { val streamOperation = mockk() every { streamOperationFactory.create(id) } returns streamOperation - val result = RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + val result = RemoteOperationResult>(RemoteOperationResult.ResultCode.OK) result.data = arrayListOf("https://stream/url.m3u8") every { streamOperation.execute(client) } returns result every { delegate.open(any()) } returns 777L @@ -109,7 +109,7 @@ class DefaultDataSourceTest { val streamOperation = mockk() every { streamOperationFactory.create(id) } returns streamOperation - val result = mockk>() + val result = mockk>>() every { result.isSuccess } returns false every { result.exception } returns RuntimeException("boom") every { streamOperation.execute(client) } returns result diff --git a/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt b/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt deleted file mode 100644 index ea69556ae48d..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2018 Tobias Kaminsky - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.os.AsyncTask -import com.owncloud.android.files.StreamMediaFileOperation -import com.owncloud.android.lib.common.OwnCloudClient - -internal class LoadUrlTask( - private val client: OwnCloudClient, - private val fileId: Long, - private val onResult: (String?) -> Unit -) : AsyncTask() { - - override fun doInBackground(vararg args: Void): String? { - val operation = StreamMediaFileOperation(fileId) - val result = operation.execute(client) - return when (result.isSuccess) { - true -> result.data[0] as String - false -> null - } - } - - override fun onPostExecute(url: String?) { - if (!isCancelled) { - onResult(url) - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt index a968ffb3ea04..fe9b7a073184 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt @@ -17,6 +17,7 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.files.StreamMediaFileOperation import com.owncloud.android.lib.common.OwnCloudClient import java.io.IOException +import androidx.core.net.toUri @UnstableApi class DefaultDataSource( @@ -47,7 +48,8 @@ class DefaultDataSource( val streamMediaFileOperation = streamOperationFactory.create(fileId) val result = streamMediaFileOperation.execute(ownCloudClient) return if (result.isSuccess) { - val uri = Uri.parse(result.data[0] as String) + val uri = (result.data[0] as? String)?.toUri() + ?: throw IllegalStateException("url is not valid, cannot stream") delegate.open(dataSpec.buildUpon(uri)) } else { throw IOException("Failed to retrieve streaming uri", result.exception) diff --git a/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt index 6d12aed0aaa1..0ebc51c9c4df 100644 --- a/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt +++ b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt @@ -1,78 +1,67 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2018 Tobias Kaminsky * SPDX-FileCopyrightText: 2018 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ -package com.owncloud.android.files; +package com.owncloud.android.files -import com.owncloud.android.lib.common.OwnCloudClient; -import com.owncloud.android.lib.common.operations.RemoteOperation; -import com.owncloud.android.lib.common.operations.RemoteOperationResult; -import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import org.apache.commons.httpclient.HttpStatus +import org.apache.commons.httpclient.methods.Utf8PostMethod +import org.json.JSONObject -import org.apache.commons.httpclient.HttpStatus; -import org.apache.commons.httpclient.methods.Utf8PostMethod; -import org.json.JSONObject; +@Suppress("TooGenericExceptionCaught") +class StreamMediaFileOperation(private val fileID: Long) : RemoteOperation>() { -import java.util.ArrayList; + @Deprecated("Deprecated in Java") + override fun run(client: OwnCloudClient): RemoteOperationResult> { + val postMethod = Utf8PostMethod(client.baseUri.toString() + STREAM_MEDIA_URL + JSON_FORMAT) -public class StreamMediaFileOperation extends RemoteOperation { - private static final String TAG = StreamMediaFileOperation.class.getSimpleName(); - private static final int SYNC_READ_TIMEOUT = 40000; - private static final int SYNC_CONNECTION_TIMEOUT = 5000; - private static final String STREAM_MEDIA_URL = "/ocs/v2.php/apps/dav/api/v1/direct"; - - private final long fileID; - - // JSON node names - private static final String NODE_OCS = "ocs"; - private static final String NODE_DATA = "data"; - private static final String NODE_URL = "url"; - private static final String JSON_FORMAT = "?format=json"; - - public StreamMediaFileOperation(long fileID) { - this.fileID = fileID; - } - - protected RemoteOperationResult run(OwnCloudClient client) { - RemoteOperationResult result; - Utf8PostMethod postMethod = null; - - try { - postMethod = new Utf8PostMethod(client.getBaseUri() + STREAM_MEDIA_URL + JSON_FORMAT); - postMethod.setParameter("fileId", String.valueOf(fileID)); - - // remote request - postMethod.addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE); + return try { + postMethod.apply { + setParameter("fileId", fileID.toString()) + addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE) + params.soTimeout = SYNC_READ_TIMEOUT + } - int status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT); + val status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT) if (status == HttpStatus.SC_OK) { - String response = postMethod.getResponseBodyAsString(); - - // Parse the response - JSONObject respJSON = new JSONObject(response); - String url = respJSON.getJSONObject(NODE_OCS).getJSONObject(NODE_DATA).getString(NODE_URL); + val response = postMethod.getResponseBodyAsString() + val url = JSONObject(response) + .getJSONObject(NODE_OCS) + .getJSONObject(NODE_DATA) + .getString(NODE_URL) - result = new RemoteOperationResult(true, postMethod); - ArrayList urlArray = new ArrayList<>(); - urlArray.add(url); - result.setData(urlArray); + RemoteOperationResult>(true, postMethod).also { + it.data = arrayListOf(url) + } } else { - result = new RemoteOperationResult(false, postMethod); - client.exhaustResponse(postMethod.getResponseBodyAsStream()); + client.exhaustResponse(postMethod.getResponseBodyAsStream()) + RemoteOperationResult(false, postMethod) } - } catch (Exception e) { - result = new RemoteOperationResult(e); - Log_OC.e(TAG, "Get stream url for file with id " + fileID + " failed: " + result.getLogMessage(), - result.getException()); + } catch (e: Exception) { + Log_OC.e(TAG, "Get stream url for file with id $fileID failed: ${e.message}", e) + RemoteOperationResult(e) } finally { - if (postMethod != null) { - postMethod.releaseConnection(); - } + postMethod.releaseConnection() } - return result; + } + + companion object { + private val TAG = StreamMediaFileOperation::class.java.simpleName + private const val SYNC_READ_TIMEOUT = 120_000 + private const val SYNC_CONNECTION_TIMEOUT = 15_000 + private const val STREAM_MEDIA_URL = "/ocs/v2.php/apps/dav/api/v1/direct" + private const val NODE_OCS = "ocs" + private const val NODE_DATA = "data" + private const val NODE_URL = "url" + private const val JSON_FORMAT = "?format=json" } } diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index 5b01b7693136..967d244f8bf9 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -411,7 +411,7 @@ public void streamMediaFile(OCFile file) { final User user = currentAccount.getUser(); new Thread(() -> { StreamMediaFileOperation sfo = new StreamMediaFileOperation(file.getLocalId()); - RemoteOperationResult result = sfo.execute(user, fileActivity); + final var result = sfo.execute(user, fileActivity); fileActivity.dismissLoadingDialog(); diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt index c7a980e6b742..cb37f45bb286 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt @@ -518,7 +518,7 @@ class PreviewMediaFragment : return null } - return (result?.data?.get(0) as String).toUri() + return (result?.data?.get(0) as? String)?.toUri() } private fun playVideoUri(uri: Uri) { From 6b0ed3f31264fec4a3d58841c17bd37cf2094ac2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:53:35 +0200 Subject: [PATCH 15/69] handle stream media operation better Signed-off-by: alperozturk96 --- .../nextcloud/client/player/ui/PlayerView.kt | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index dd1121ab1a3f..4b62e0dac634 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -17,6 +17,7 @@ import androidx.annotation.LayoutRes import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.player.model.PlaybackModel import com.nextcloud.client.player.model.error.SourceException import com.nextcloud.client.player.model.file.PlaybackFile @@ -28,12 +29,13 @@ import com.nextcloud.client.player.ui.pager.PlayerPagerMode import com.nextcloud.client.player.util.WindowWrapper import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation -import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.DownloadFileOperation import com.owncloud.android.utils.DisplayUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject import kotlin.jvm.optionals.getOrNull @@ -104,35 +106,32 @@ abstract class PlayerView @JvmOverloads constructor( override fun onPlaybackError(error: Throwable) { if (error is SourceException) { - // TODO: - REMOVE OR HANDLE BETTER - val currentFile = playbackModel.state.getOrNull()?.currentItemState?.file - Log_OC.d(TAG, "current file, id: " + currentFile?.id + " _ name: " + currentFile?.name) - - playerPager.getItems().forEach { - Log_OC.d(TAG, "pager file, id: " + it.id + " _ name: " + it.name) - } - - playbackModel.state.getOrNull()?.currentFiles?.forEach { - Log_OC.d(TAG, "files, id: " + it.id + " _ name: " + it.name) - } + downloadFile() + } else { + DisplayUtils.showSnackMessage(this, R.string.common_error_unknown) + } + } - val storageManager = FileDataStorageManager(userAccountManager.user, context.contentResolver) - val file = currentFile?.id?.toLong()?.let { storageManager.getFileByLocalId(it) } - Log_OC.d(TAG, "oc_file: " + file?.decryptedRemotePath) - - activity.lifecycleScope.launch(Dispatchers.IO) { - val operation = ReadFileRemoteOperation(file?.decryptedRemotePath) - val result = operation.execute(userAccountManager.user, context) - if (result.isSuccess) { - val remoteFile = result.data[0] as RemoteFile - Log_OC.d(TAG, "file is successfully read::: " + remoteFile.remotePath) - } else { - Log_OC.e(TAG, "cannot read file") + private fun downloadFile() { + val currentFile = playbackModel.state.getOrNull()?.currentItemState?.file + val storageManager = FileDataStorageManager(userAccountManager.user, context.contentResolver) + val file = currentFile?.id?.toLong()?.let { storageManager.getFileByLocalId(it) } + + activity.lifecycleScope.launch(Dispatchers.IO) { + val operation = DownloadFileOperation(userAccountManager.user, file, context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(userAccountManager.currentOwnCloudAccount, context) + val result = operation.execute(client) + if (result.isSuccess) { + Log_OC.d(TAG, "file is successfully downloaded") + val helper = FileDownloadHelper() + file?.let { helper.saveFile(it, operation, storageManager) } + } else { + Log_OC.e(TAG, "cannot download file") + withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage(this@PlayerView, R.string.player_error_source_not_found) } } - DisplayUtils.showSnackMessage(this, R.string.player_error_source_not_found) - } else { - DisplayUtils.showSnackMessage(this, R.string.common_error_unknown) } } From 4e98d9e39e017a980e1a243c624a57965eff0323 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 16:03:06 +0200 Subject: [PATCH 16/69] handle stream media operation better Signed-off-by: alperozturk96 --- app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index 4b62e0dac634..c6f04e6deab5 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -112,6 +112,7 @@ abstract class PlayerView @JvmOverloads constructor( } } + // TODO: HANDLE OR NEEDED AT ALL? private fun downloadFile() { val currentFile = playbackModel.state.getOrNull()?.currentItemState?.file val storageManager = FileDataStorageManager(userAccountManager.user, context.contentResolver) From 61069b36a44680f0afbe97cb6a3f7ea72a4d5e4f Mon Sep 17 00:00:00 2001 From: Alex Kucherenko Date: Fri, 22 May 2026 00:44:50 +0300 Subject: [PATCH 17/69] Update license identifier Signed-off-by: daniele-verducci --- app/src/main/java/com/nextcloud/client/player/PlayerModule.kt | 2 +- .../java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt | 2 +- .../com/nextcloud/client/player/media3/Media3PlaybackModel.kt | 2 +- .../nextcloud/client/player/media3/MediaNotificationProvider.kt | 2 +- .../client/player/media3/PlaybackModelPlayerListener.kt | 2 +- .../java/com/nextcloud/client/player/media3/PlaybackService.kt | 2 +- .../com/nextcloud/client/player/media3/PlaybackStateFactory.kt | 2 +- .../nextcloud/client/player/media3/common/MediaItemFactory.kt | 2 +- .../com/nextcloud/client/player/media3/common/MediaMetadata.kt | 2 +- .../com/nextcloud/client/player/media3/common/PlayerFactory.kt | 2 +- .../player/media3/controller/DefaultMediaControllerFactory.kt | 2 +- .../client/player/media3/controller/MediaController.kt | 2 +- .../client/player/media3/controller/MediaControllerFactory.kt | 2 +- .../client/player/media3/datasource/DefaultDataSource.kt | 2 +- .../client/player/media3/datasource/DefaultDataSourceFactory.kt | 2 +- .../client/player/media3/resumption/PlaybackResumptionConfig.kt | 2 +- .../player/media3/resumption/PlaybackResumptionConfigStore.kt | 2 +- .../player/media3/resumption/PlaybackResumptionLauncher.kt | 2 +- .../media3/resumption/PlaybackResumptionPlayerListener.kt | 2 +- .../client/player/media3/session/DefaultMediaSessionFactory.kt | 2 +- .../client/player/media3/session/MediaSessionActivityFactory.kt | 2 +- .../client/player/media3/session/MediaSessionBitmapLoader.kt | 2 +- .../client/player/media3/session/MediaSessionCallback.kt | 2 +- .../client/player/media3/session/MediaSessionFactory.kt | 2 +- .../client/player/media3/session/MediaSessionHolder.kt | 2 +- .../com/nextcloud/client/player/model/GlideThumbnailLoader.kt | 2 +- .../java/com/nextcloud/client/player/model/PlaybackModel.kt | 2 +- .../client/player/model/PlaybackModelCompositeListener.kt | 2 +- .../java/com/nextcloud/client/player/model/PlaybackSettings.kt | 2 +- .../java/com/nextcloud/client/player/model/ThumbnailLoader.kt | 2 +- .../client/player/model/error/DefaultPlaybackErrorStrategy.kt | 2 +- .../client/player/model/error/PlaybackErrorStrategy.kt | 2 +- .../com/nextcloud/client/player/model/error/SourceException.kt | 2 +- .../java/com/nextcloud/client/player/model/file/PlaybackFile.kt | 2 +- .../nextcloud/client/player/model/file/PlaybackFileMapper.kt | 2 +- .../com/nextcloud/client/player/model/file/PlaybackFileType.kt | 2 +- .../nextcloud/client/player/model/file/PlaybackFileUriMapper.kt | 2 +- .../com/nextcloud/client/player/model/file/PlaybackFiles.kt | 2 +- .../client/player/model/file/PlaybackFilesComparator.kt | 2 +- .../client/player/model/file/PlaybackFilesRepository.kt | 2 +- .../nextcloud/client/player/model/state/PlaybackItemMetadata.kt | 2 +- .../nextcloud/client/player/model/state/PlaybackItemState.kt | 2 +- .../com/nextcloud/client/player/model/state/PlaybackState.kt | 2 +- .../java/com/nextcloud/client/player/model/state/PlayerState.kt | 2 +- .../java/com/nextcloud/client/player/model/state/RepeatMode.kt | 2 +- .../java/com/nextcloud/client/player/model/state/VideoSize.kt | 2 +- .../main/java/com/nextcloud/client/player/ui/PlayerActivity.kt | 2 +- .../main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt | 2 +- .../com/nextcloud/client/player/ui/PlayerProgressIndicator.kt | 2 +- .../java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt | 2 +- app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt | 2 +- .../main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt | 2 +- .../com/nextcloud/client/player/ui/audio/AudioFileFragment.kt | 2 +- .../client/player/ui/audio/AudioFileFragmentFactory.kt | 2 +- .../com/nextcloud/client/player/ui/audio/AudioPlayerView.kt | 2 +- .../nextcloud/client/player/ui/control/MultipleClickListener.kt | 2 +- .../com/nextcloud/client/player/ui/control/PlayerControlView.kt | 2 +- .../java/com/nextcloud/client/player/ui/pager/PlayerPager.kt | 2 +- .../client/player/ui/pager/PlayerPagerFragmentFactory.kt | 2 +- .../com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt | 2 +- .../com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt | 2 +- .../player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt | 2 +- .../player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt | 2 +- .../com/nextcloud/client/player/ui/video/VideoFileFragment.kt | 2 +- .../client/player/ui/video/VideoFileFragmentFactory.kt | 2 +- .../com/nextcloud/client/player/ui/video/VideoPlayerView.kt | 2 +- .../java/com/nextcloud/client/player/util/ContentResolver.kt | 2 +- app/src/main/java/com/nextcloud/client/player/util/Context.kt | 2 +- app/src/main/java/com/nextcloud/client/player/util/ImageView.kt | 2 +- app/src/main/java/com/nextcloud/client/player/util/List.kt | 2 +- .../java/com/nextcloud/client/player/util/PeriodicAction.kt | 2 +- .../main/java/com/nextcloud/client/player/util/ScreenUtils.kt | 2 +- .../main/java/com/nextcloud/client/player/util/WindowWrapper.kt | 2 +- 73 files changed, 73 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt index 6e9245f7f44d..2f548abaa7dd 100644 --- a/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt +++ b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player diff --git a/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt index 405bf8dd61a0..f7af1e8c1645 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3 diff --git a/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt index 1756579ebed1..a79587f27993 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3 diff --git a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt index e0bacb9a281a..8d2f7299bddf 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3 diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt index 6e619585ba1b..9c7ee00b7218 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3 diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt index 7cba91115f14..5981a564df71 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3 diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt index 2df4f9e53d37..97b5dccfec64 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3 diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt index 1c0d84ae7921..d3ac79d1eba2 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.common diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt index b007d1b94b70..92011c2d83c7 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.common diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt index ea6eab84f46f..cedadc1dcbf3 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.common diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt index 08c2322805e2..f0eaf1b8d19a 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.controller diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt index 3e46d1f4df3f..d619784ae62e 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.controller diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt index 3eb015171f67..0ac31b8b174f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.controller diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt index fe9b7a073184..1d0158055a6a 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.datasource diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt index bd75383603cb..d3221d6da1e1 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.datasource diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt index 7b384b2b915e..6c6fb1feb28b 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.resumption diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt index 2be370d25ba0..6d3a99e877ce 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.resumption diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt index 4ac9ed1f2119..16f1aab6db59 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.resumption diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt index 1b1b7d10ef8b..6b8f8d4ef00e 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.resumption diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt index bd6ae4d8ccd1..89e47a5fc98c 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.session diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt index 64376ca85714..f0309fd1cb46 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.session diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt index 385a3796507b..c4a5c466a19a 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.session diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt index 0bc991a4ce66..e5764fee28c4 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.session diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt index 5f050f77dfa0..f08b998b60de 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.session diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt index d94bb3565ceb..5387140d3a66 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.media3.session diff --git a/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt index 47ab6377bf31..8e41331ec4e8 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt index eaeb5ab2c87f..626227500220 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt index c74d1eecf416..c97987d43979 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt index 3911747d833b..1bfa906b4c21 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model diff --git a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt index 5f9a1c9da755..a2e997292fda 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt b/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt index fc1feceb9db3..4badebd01016 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.error diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt b/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt index 2a8073837a28..357a6e930876 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.error diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt b/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt index 800fd0cd99b4..9e13b74fc778 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.error diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt index a3d04362d2bb..682f085b181a 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.file diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt index cb357000604d..9993f89172c3 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.file diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt index 5dc57c4fe6b5..ab151fd9f2f1 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.file diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt index c6bc9b20c299..562a2e3a2d53 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.file diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt index cc5d1a04752c..839dc8e68131 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.file diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt index 0b977ab02374..5bdfc10ae58c 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.file diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt index f000b9252b65..5ecdb55d282d 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.file diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt index 6653376c7e94..451f96dc10d3 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.state diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt index 6d60cd0dc576..735aa448f282 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.state diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt index 93f17811b5f4..6ec65e6e92cb 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.state diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt index fd5743cc7adc..1caf340706f2 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.state diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt index ac07ac9bc28f..91d0a30ee140 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.state diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt b/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt index 4d2a19490fbc..9e94d2b4a922 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.model.state diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 91a8f10efd9a..dd7e9adbb9de 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt index bb237142dc4f..295ad1ef4312 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt index 04fbe09a9da9..f34284b36c6b 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt index 07a25bafb0cb..c5b0d23f066f 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index c6f04e6deab5..6f4e406ec5ca 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt index 49f63a084daf..1c6849f1d18c 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt index 359f09afe120..a1c7dd213741 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.audio diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt index 94d6965431ec..2b1b39769478 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.audio diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt index e13b421eb1cd..c4202c11019f 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.audio diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt index 6002bef005f7..a5eb7fb49c8b 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.control diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index 18ce545c02da..fa11e66df09f 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.control diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt index 51a917b8ad0f..d26b02b46cba 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.pager diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt index d8c439a04973..db33e12e2d33 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.pager diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt index 98e2757e2d23..c109f6614e32 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.pager diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt index c694d0ffea4c..ae84941fe569 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.pager diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt index de7776511e63..9d657583f4dc 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.pager.adapter diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt index 586936eb93be..d27a9e094bed 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.pager.adapter diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index 0cbbba4c02d7..8f6a08743e20 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.video diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt index fbff0021ee02..6a7a835e841c 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.video diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt index aeaf9ead341b..da7553232c74 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.ui.video diff --git a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt index ae4ce725a974..b61117bf5987 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.util diff --git a/app/src/main/java/com/nextcloud/client/player/util/Context.kt b/app/src/main/java/com/nextcloud/client/player/util/Context.kt index 5ebc3083d5f0..7a7080565b95 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/Context.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/Context.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.util diff --git a/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt b/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt index 7e3b2fafc630..a0f3f21c5e28 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.util diff --git a/app/src/main/java/com/nextcloud/client/player/util/List.kt b/app/src/main/java/com/nextcloud/client/player/util/List.kt index 5084a1c27059..502d36d8774b 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/List.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/List.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.util diff --git a/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt b/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt index 91890a763812..b815070c9cb4 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.util diff --git a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt index e8e1b1818005..a9e420cac91b 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ @file:JvmName("ScreenUtils") diff --git a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt index 69d80775667d..5a8fa141836d 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.client.player.util From 60d72c9990f74d4027451317b20d3ec2da6c0389 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 14 Aug 2026 09:18:42 +0200 Subject: [PATCH 18/69] wip Signed-off-by: alperozturk96 --- .../model/file/PlaybackFilesComparator.kt | 6 +- .../nextcloud/client/player/ui/PlayerView.kt | 5 +- .../android/ui/activity/FileActivity.java | 4 +- .../ui/activity/FileDisplayActivity.kt | 44 ++++------ .../ui/fragment/OCFileListFragment.java | 3 +- .../ui/preview/PreviewMediaPagerAdapter.kt | 46 ++++------- .../res/layout/activity_preview_media.xml | 82 ------------------- app/src/main/res/values/styles.xml | 39 +++++++++ gradle/libs.versions.toml | 4 + 9 files changed, 83 insertions(+), 150 deletions(-) delete mode 100644 app/src/main/res/layout/activity_preview_media.xml diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt index 5bdfc10ae58c..674d010b24e9 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt @@ -8,7 +8,7 @@ package com.nextcloud.client.player.model.file import com.owncloud.android.utils.FileSortOrder -import third_parties.daveKoeller.AlphanumComparator +import com.owncloud.android.utils.sort.AlphanumericComparator sealed interface PlaybackFilesComparator : Comparator { @@ -17,7 +17,7 @@ sealed interface PlaybackFilesComparator : Comparator { } object FAVORITE : PlaybackFilesComparator { - override fun compare(a: PlaybackFile, b: PlaybackFile): Int = AlphanumComparator.compare(a.name, b.name) + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = AlphanumericComparator.compare(a.name, b.name) } object GALLERY : PlaybackFilesComparator { @@ -35,7 +35,7 @@ sealed interface PlaybackFilesComparator : Comparator { private fun createDelegate(): Comparator { val sortTypeComparator: Comparator = when (sortType) { - FileSortOrder.SortType.ALPHABET -> Comparator { a, b -> AlphanumComparator.compare(a.name, b.name) } + FileSortOrder.SortType.ALPHABET -> Comparator { a, b -> AlphanumericComparator.compare(a.name, b.name) } FileSortOrder.SortType.SIZE -> compareBy { it.contentLength } FileSortOrder.SortType.DATE -> compareBy { it.lastModified } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index 6f4e406ec5ca..d9b46fe8c8de 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -112,11 +112,10 @@ abstract class PlayerView @JvmOverloads constructor( } } - // TODO: HANDLE OR NEEDED AT ALL? private fun downloadFile() { val currentFile = playbackModel.state.getOrNull()?.currentItemState?.file val storageManager = FileDataStorageManager(userAccountManager.user, context.contentResolver) - val file = currentFile?.id?.toLong()?.let { storageManager.getFileByLocalId(it) } + val file = currentFile?.id?.toLong()?.let { storageManager.getFileByLocalId(it) } ?: return activity.lifecycleScope.launch(Dispatchers.IO) { val operation = DownloadFileOperation(userAccountManager.user, file, context) @@ -126,7 +125,7 @@ abstract class PlayerView @JvmOverloads constructor( if (result.isSuccess) { Log_OC.d(TAG, "file is successfully downloaded") val helper = FileDownloadHelper() - file?.let { helper.saveFile(it, operation, storageManager) } + helper.saveFile(file, operation, storageManager) } else { Log_OC.e(TAG, "cannot download file") withContext(Dispatchers.Main) { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java index afbac98fa2d9..a097a6b2d47a 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java @@ -37,6 +37,7 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.client.network.NetworkChangeListener; +import com.nextcloud.client.player.ui.PlayerActivity; import com.nextcloud.utils.EditorUtils; import com.nextcloud.utils.extensions.ActivityExtensionsKt; import com.nextcloud.utils.extensions.BundleExtensionsKt; @@ -94,7 +95,6 @@ import com.owncloud.android.ui.fragment.filesRepository.RemoteFilesRepository; import com.owncloud.android.ui.helpers.FileOperationsHelper; import com.owncloud.android.ui.preview.PreviewImageActivity; -import com.owncloud.android.ui.preview.PreviewMediaActivity; import com.owncloud.android.utils.ClipboardUtil; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.ErrorMessageAdapter; @@ -254,7 +254,7 @@ public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailab refreshList(); } } else { - if (this instanceof PreviewMediaActivity) { + if (this instanceof PlayerActivity) { hideInfoBox(); } else { showInfoBox(R.string.offline_mode); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index db54643b7786..157c45f67291 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -71,8 +71,8 @@ import com.nextcloud.client.jobs.folderDownload.FolderDownloadEventBroadcaster import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker -import com.nextcloud.client.media.PlayerServiceConnection import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.player.ui.PlayerLauncher import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.utils.IntentUtil import com.nextcloud.model.OCUploadLocalPathData @@ -150,7 +150,6 @@ import com.owncloud.android.ui.interfaces.TransactionInterface import com.owncloud.android.ui.navigation.NavigatorScreen import com.owncloud.android.ui.preview.PreviewImageActivity import com.owncloud.android.ui.preview.PreviewImageFragment -import com.owncloud.android.ui.preview.PreviewMediaActivity import com.owncloud.android.ui.preview.PreviewMediaFragment import com.owncloud.android.ui.preview.PreviewMediaFragment.Companion.newInstance import com.owncloud.android.ui.preview.PreviewTextFileFragment @@ -234,7 +233,6 @@ class FileDisplayActivity : private var searchOpen = false private var searchView: SearchView? = null - private var mPlayerConnection: PlayerServiceConnection? = null private var lastDisplayedAccountName: String? = null // needed for first time app launch multiple listing directory call @@ -242,6 +240,9 @@ class FileDisplayActivity : // causing also empty state thus this flag is used. private var listFragmentJustCreated = false + @Inject + lateinit var playerLauncher: PlayerLauncher + @Inject lateinit var localBroadcastManager: LocalBroadcastManager @@ -312,10 +313,7 @@ class FileDisplayActivity : showSortListGroup(savedInstanceState.getBoolean(KEY_IS_SORT_GROUP_VISIBLE)) } - mPlayerConnection = PlayerServiceConnection(this) - checkStoragePath() - observeWorkerState() startMetadataSyncForRoot() handleBackPress() @@ -883,6 +881,9 @@ class FileDisplayActivity : } } + fun canMediaPreviewed(file: OCFile?): Boolean = + file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) + private fun tryStartWaitingPreview(success: Boolean): Boolean { if (!success) return false @@ -895,7 +896,7 @@ class FileDisplayActivity : true } - PreviewMediaActivity.canBePreviewed(file) -> { + canMediaPreviewed(file) -> { startMediaPreview(file, 0, true, true, true, true) true } @@ -2087,7 +2088,7 @@ class FileDisplayActivity : } else if (MimeTypeUtil.isVideo(file)) { setFabVisible?.onComplete(false) startImagePreview(file, true) - } else if (PreviewMediaActivity.Companion.canBePreviewed(file)) { + } else if (canMediaPreviewed(file)) { setFabVisible?.onComplete(false) startMediaPreview(file, 0, true, true, false, true) } else { @@ -2258,7 +2259,7 @@ class FileDisplayActivity : } val removedFile = operation.file - tryStopPlaying(removedFile) + file?.let { playbackModel.stopPlaying(it) } val leftFragment = this.leftFragment // check if file is still available, if so do nothing @@ -2363,13 +2364,6 @@ class FileDisplayActivity : } } - private fun tryStopPlaying(file: OCFile) { - // placeholder for stop-on-delete future code - if (mPlayerConnection != null && MimeTypeUtil.isAudio(file) && mPlayerConnection?.isPlaying() == true) { - mPlayerConnection?.stop(file) - } - } - /** * Updates the view associated to the activity after the finish of an operation trying to move a file. * @@ -2767,10 +2761,9 @@ class FileDisplayActivity : if (!user.isPresent) { return // not reachable under normal conditions } - val actualUser = user.get() if ((showPreview && file.isDown && !file.isDownloading) || streamMedia) { if (showInActivity) { - startMediaActivity(file, startPlaybackPosition, autoplay, actualUser) + startMediaActivity(file) } else { configureToolbarForPreview(file) val mediaFragment: Fragment = newInstance(file, user.get(), startPlaybackPosition, autoplay) @@ -2787,18 +2780,9 @@ class FileDisplayActivity : } } - private fun startMediaActivity(file: OCFile?, startPlaybackPosition: Long, autoplay: Boolean, user: User?) { - val previewMediaIntent = Intent(this, PreviewMediaActivity::class.java) - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_FILE, file) - - // Safely handle the absence of a user - if (user != null) { - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_USER, user) - } - - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_START_POSITION, startPlaybackPosition) - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_AUTOPLAY, autoplay) - startActivity(previewMediaIntent) + private fun startMediaActivity(file: OCFile) { + val searchType = listOfFilesFragment?.currentSearchType + playerLauncher.launch(this, file, searchType) } fun configureToolbarForPreview(file: OCFile?) { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 31cdbeb107ec..25d8afed1f54 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -110,7 +110,6 @@ import com.owncloud.android.ui.helpers.FileOperationsHelper; import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface; import com.owncloud.android.ui.preview.PreviewImageFragment; -import com.owncloud.android.ui.preview.PreviewMediaActivity; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; @@ -1225,7 +1224,7 @@ private void handlePendingDownloadFile(OCFile file) { if (MimeTypeUtil.isVideo(file) && !file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda) { setFabVisible(false); fda.startImagePreview(file, true, null); - } else if (PreviewMediaActivity.Companion.canBePreviewed(file) && !file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda) { + } else if (!file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda && fda.canMediaPreviewed(file)) { setFabVisible(false); fda.startMediaPreview(file, 0, true, true, true, true); } else if (webViewAvailable && editorUtils.getEditor(accountManager.getUser(), file.getMimeType()) != null && !file.isEncrypted()) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index 919f1e98d24f..f3314d634849 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -1,7 +1,6 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2025 TSI-mc * SPDX-FileCopyrightText: 2023 Alper Ozturk * SPDX-FileCopyrightText: 2018 Tobias Kaminsky * SPDX-FileCopyrightText: 2020 Chris Narkiewicz @@ -30,7 +29,7 @@ import com.owncloud.android.utils.FileStorageUtils class PreviewMediaPagerAdapter : FragmentStateAdapter { private var selectedFile: OCFile? = null - private var imageFiles: MutableList = mutableListOf() + private var mediaFiles: MutableList = mutableListOf() private val user: User private val mObsoleteFragments: MutableSet private val mObsoletePositions: MutableSet @@ -61,12 +60,12 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this.user = user this.selectedFile = selectedFile mStorageManager = storageManager - imageFiles = mStorageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) + mediaFiles = mStorageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) val sortOrder = preferences.getSortOrderByFolder(parentFolder) val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() val favoritesFirst = preferences.isSortFavoritesFirst() - imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) + mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) mObsoleteFragments = HashSet() mObsoletePositions = HashSet() @@ -95,27 +94,18 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this.user = user mStorageManager = storageManager - when (type) { - VirtualFolderType.GALLERY -> { - imageFiles = mStorageManager.allGalleryItems - imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) - } - - VirtualFolderType.ALBUM -> { - imageFiles = mStorageManager.getVirtualFolderContent(type, false) - imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) - } - - else -> { - imageFiles = mStorageManager.getVirtualFolderContent(type, true) - } + if (type == VirtualFolderType.GALLERY) { + mediaFiles = mStorageManager.allGalleryItems + mediaFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) + } else { + mediaFiles = mStorageManager.getVirtualFolderContent(type, true) } if (type == VirtualFolderType.FAVORITE) { val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() val favoritesFirst = preferences.isSortFavoritesFirst() - imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) + mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) } mObsoleteFragments = HashSet() @@ -125,7 +115,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } fun delete(position: Int) { - if (position < 0 || position >= imageFiles.size) { + if (position < 0 || position >= mediaFiles.size) { return } @@ -135,7 +125,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { mObsoletePositions.add(position) - imageFiles.removeAt(position) + mediaFiles.removeAt(position) mDownloadErrors.remove(position) mCachedFragments.remove(position) @@ -144,7 +134,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { @Suppress("TooGenericExceptionCaught") fun getFileAt(position: Int): OCFile? = try { - imageFiles[position] + mediaFiles[position] } catch (_: IndexOutOfBoundsException) { null } @@ -190,28 +180,28 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } } - fun getFilePosition(file: OCFile): Int = imageFiles.indexOf(file) + fun getFilePosition(file: OCFile): Int = mediaFiles.indexOf(file) fun updateFile(position: Int, file: OCFile) { - if (position < 0 || position >= imageFiles.size) { + if (position < 0 || position >= mediaFiles.size) { return } mCachedFragments[position]?.let { mObsoleteFragments.add(it) } mObsoletePositions.add(position) - imageFiles[position] = file + mediaFiles[position] = file } fun pendingErrorAt(position: Int): Boolean = mDownloadErrors.contains(position) override fun createFragment(position: Int): Fragment = getItem(position) - override fun getItemCount(): Int = imageFiles.size + override fun getItemCount(): Int = mediaFiles.size override fun getItemId(position: Int): Long { // The item ID function is needed to detect whether the deletion of the current item needs a UI update - return imageFiles.getOrNull(position)?.fileId ?: position.toLong() + return mediaFiles.getOrNull(position)?.fileId ?: position.toLong() } - override fun containsItem(itemId: Long): Boolean = imageFiles.any { it.fileId == itemId } + override fun containsItem(itemId: Long): Boolean = mediaFiles.any { it.fileId == itemId } } diff --git a/app/src/main/res/layout/activity_preview_media.xml b/app/src/main/res/layout/activity_preview_media.xml deleted file mode 100644 index e677a7776d98..000000000000 --- a/app/src/main/res/layout/activity_preview_media.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ad6d46e6e699..a824d856fa64 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -522,4 +522,43 @@ + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2858e905d32a..dceaad669f43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,6 +87,7 @@ workRuntime = "2.11.2" foundationVersion = "1.12.0" browserVersion = "1.10.0" kotlinxCoroutinesTestVersion = "1.11.0" +kotlinxCoroutinesVersion = "1.11.0" [libraries] # Crypto @@ -239,6 +240,9 @@ work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRunti work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" } foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesVersion" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesVersion" } + [bundles] media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource"] espresso = ["espresso-core", "espresso-contrib", "espresso-web", "espresso-accessibility", "espresso-intents", "espresso-idling-resource"] From 70681271a07043516e121901cd88b836138bc55b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 14 Aug 2026 10:35:40 +0200 Subject: [PATCH 19/69] wip Signed-off-by: alperozturk96 --- app/lint.xml | 5 +++++ .../java/com/nextcloud/client/player/ui/PlayerActivity.kt | 2 +- app/src/main/res/layout/player_audio_file_fragment.xml | 2 ++ app/src/main/res/layout/player_video_file_fragment.xml | 4 +++- app/src/main/res/values/styles.xml | 5 ----- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/lint.xml b/app/lint.xml index 2316b422a838..848a5919cd87 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -76,6 +76,11 @@ + + + + + diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index dd7e9adbb9de..50c063d9828a 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -182,7 +182,7 @@ class PlayerActivity : if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { it.setAutoEnterEnabled(true) } - getSourceRectHint().let(it::setSourceRectHint) + it.setSourceRectHint(getSourceRectHint()) it.build() } diff --git a/app/src/main/res/layout/player_audio_file_fragment.xml b/app/src/main/res/layout/player_audio_file_fragment.xml index 2e2613b4668d..d4ca5a968578 100644 --- a/app/src/main/res/layout/player_audio_file_fragment.xml +++ b/app/src/main/res/layout/player_audio_file_fragment.xml @@ -18,6 +18,7 @@ android:id="@+id/icon" android:layout_width="0dp" android:layout_height="0dp" + android:importantForAccessibility="no" android:layout_marginTop="@dimen/player_audio_album_cover_margin_vertical" android:layout_marginBottom="@dimen/player_audio_album_cover_margin_vertical" android:src="@drawable/player_ic_audio" @@ -32,6 +33,7 @@ android:id="@+id/album_cover" android:layout_width="0dp" android:layout_height="0dp" + android:importantForAccessibility="no" android:layout_marginTop="@dimen/player_audio_album_cover_margin_vertical" android:layout_marginBottom="@dimen/player_audio_album_cover_margin_vertical" custom:layout_constraintBottom_toTopOf="@id/title" diff --git a/app/src/main/res/layout/player_video_file_fragment.xml b/app/src/main/res/layout/player_video_file_fragment.xml index 94e7e6a774e7..d31b9e3c05f4 100644 --- a/app/src/main/res/layout/player_video_file_fragment.xml +++ b/app/src/main/res/layout/player_video_file_fragment.xml @@ -15,12 +15,14 @@ android:layout_width="@dimen/player_file_icon_size" android:layout_height="@dimen/player_file_icon_size" android:layout_gravity="center" + android:importantForAccessibility="no" android:src="@drawable/player_ic_video" /> + android:layout_height="match_parent" + android:importantForAccessibility="no" /> false - - From 0e57cf845da64c4a5b20dbe3789d80a8ab430d47 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 14 Aug 2026 11:04:52 +0200 Subject: [PATCH 20/69] wip Signed-off-by: alperozturk96 --- .../com/owncloud/android/ui/adapter/OCFileListAdapter.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java index c64811a69ed5..4aa7ae79761f 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -552,8 +552,9 @@ private void setFilenameAndExtension(ListGridItemViewHolder holder, OCFile file) handleListMode(holder, filename, pair); } - if (holder.getPlayerProgressIndicator() != null) { - holder.getPlayerProgressIndicator().setFile(file); + final var playerProgressIndicator = holder.getPlayerProgressIndicator(); + if (playerProgressIndicator != null) { + playerProgressIndicator.setFile(file); } } From e28aec5bd48ac8f4292a6d7667a47be329305c6d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 14:57:21 +0200 Subject: [PATCH 21/69] wip Signed-off-by: alperozturk96 --- .../ui/adapter/ListGridItemViewHolder.kt | 2 + .../ui/adapter/OCFileListItemViewHolder.kt | 4 +- .../ui/preview/PreviewMediaActivity.kt | 917 ------------------ 3 files changed, 5 insertions(+), 918 deletions(-) delete mode 100644 app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt index 615b71543fa6..f586ed12ef2f 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt @@ -9,10 +9,12 @@ package com.owncloud.android.ui.adapter import android.view.View import android.widget.TextView +import com.nextcloud.client.player.ui.PlayerProgressIndicator internal interface ListGridItemViewHolder : ListViewHolder { val fileName: TextView val extension: TextView? + val playerProgressIndicator: PlayerProgressIndicator? get() = null val bidiFilename: TextView val bidiFilenameContainer: View } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt index 919a394911d6..0bc522560bf6 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt @@ -16,6 +16,7 @@ import androidx.recyclerview.widget.RecyclerView import com.elyeproj.loaderviewlibrary.LoaderImageView import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup +import com.nextcloud.client.player.ui.PlayerProgressIndicator import com.owncloud.android.databinding.ListItemBinding import com.owncloud.android.ui.AvatarGroupLayout @@ -32,7 +33,8 @@ class OCFileListItemViewHolder(private var binding: ListItemBinding) : get() = binding.livePhotoIndicatorSeparator override val hasVisibleFeatureIndicators: Boolean get() = false - + override val playerProgressIndicator: PlayerProgressIndicator + get() = binding.playerProgressIndicator override val fileSize: TextView get() = binding.fileSize override val fileSizeSeparator: View diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt deleted file mode 100644 index 4e195084c70b..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ /dev/null @@ -1,917 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2023 Alper Ozturk - * SPDX-FileCopyrightText: 2023-2026 TSI-mc - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2016 David A. Velasco - * SPDX-FileCopyrightText: 2016 ownCloud Inc. - * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) - */ -package com.owncloud.android.ui.preview - -import android.content.ComponentName -import android.content.DialogInterface -import android.content.Intent -import android.content.res.Configuration -import android.content.res.Resources -import android.graphics.BitmapFactory -import android.graphics.Color -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.AsyncTask -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.annotation.OptIn -import androidx.annotation.StringRes -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.DrawableCompat -import androidx.core.graphics.drawable.toDrawable -import androidx.core.net.toUri -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.marginBottom -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaController -import androidx.media3.session.MediaSession -import androidx.media3.session.SessionToken -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.MoreExecutors -import com.nextcloud.client.account.User -import com.nextcloud.client.di.Injectable -import com.nextcloud.client.jobs.download.FileDownloadHelper -import com.nextcloud.client.jobs.download.SendShareDownloader -import com.nextcloud.client.media.BackgroundPlayerService -import com.nextcloud.client.media.ErrorFormat -import com.nextcloud.client.media.ExoplayerListener -import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.client.network.ClientFactory.CreationException -import com.nextcloud.common.NextcloudClient -import com.nextcloud.ui.fileactions.FileAction -import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance -import com.nextcloud.ui.fileactions.FileActionsBottomSheet.ResultListener -import com.nextcloud.utils.extensions.getParcelableArgument -import com.nextcloud.utils.extensions.logFileSize -import com.nextcloud.utils.extensions.setFullscreenButton -import com.nextcloud.utils.extensions.setTitleColor -import com.owncloud.android.R -import com.owncloud.android.databinding.ActivityPreviewMediaBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.files.StreamMediaFileOperation -import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.operations.OnRemoteOperationListener -import com.owncloud.android.lib.common.operations.RemoteOperation -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.operations.DownloadType -import com.owncloud.android.operations.FetchRemoteFileOperation -import com.owncloud.android.operations.RemoveFileOperation -import com.owncloud.android.operations.SynchronizeFileOperation -import com.owncloud.android.operations.albums.CopyFileToAlbumOperation -import com.owncloud.android.ui.activity.FileActivity -import com.owncloud.android.ui.activity.FileDisplayActivity -import com.owncloud.android.ui.dialog.ConfirmationDialogFragment -import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment -import com.owncloud.android.ui.dialog.SendShareDialog -import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.ErrorMessageAdapter -import com.owncloud.android.utils.MimeTypeUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.lang.ref.WeakReference - -/** - * This activity shows a preview of a downloaded media file (audio or video). - * - * - * Trying to get an instance with NULL [OCFile] or ownCloud [User] values will produce an [ ]. - * - * - * By now, if the [OCFile] passed is not downloaded, an [IllegalStateException] is generated on - * instantiation too. - */ -@Suppress("TooManyFunctions") -@OptIn(UnstableApi::class) -class PreviewMediaActivity : - FileActivity(), - FileFragment.ContainerActivity, - OnRemoteOperationListener, - SendShareDialog.SendShareDialogDownloader, - Injectable { - - private var user: User? = null - private var savedPlaybackPosition: Long = 0 - private var autoplay = true - private var streamUri: Uri? = null - private var nextcloudClient: NextcloudClient? = null - - private val sendShareDownloader by lazy { SendShareDownloader(this) } - - private lateinit var binding: ActivityPreviewMediaBinding - - private val exoplayerView: PlayerView - get() = binding.exoplayerView.root - - private var emptyListView: ViewGroup? = null - private var videoPlayer: ExoPlayer? = null - private var videoMediaSession: MediaSession? = null - private var audioMediaController: MediaController? = null - private var mediaControllerFuture: ListenableFuture? = null - private var fullscreenDialog: PreviewVideoFullscreenDialog? = null - private lateinit var windowInsetsController: WindowInsetsControllerCompat - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding = ActivityPreviewMediaBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.materialToolbar) - WindowCompat.setDecorFitsSystemWindows(window, false) - applyWindowInsets() - initArguments(savedInstanceState) - if (MimeTypeUtil.isVideo(file)) { - // release any background media session if exists - sendAudioSessionReleaseBroadcast() - } else if (MimeTypeUtil.isAudio(file)) { - val stopPlayer = Intent(BackgroundPlayerService.STOP_MEDIA_SESSION_BROADCAST_ACTION).apply { - setPackage(packageName) - } - sendBroadcast(stopPlayer) - } - - showMediaTypeViews() - configureSystemBars() - emptyListView = binding.emptyView.emptyListView - showProgressLayout() - - lifecycle.addObserver(sendShareDownloader) - sendShareDownloader.restoreState(savedInstanceState) - - if (file == null) { - return - } - if (MimeTypeUtil.isAudio(file)) { - setGenericThumbnail() - initializeAudioPlayer() - } - } - - private fun sendAudioSessionReleaseBroadcast() { - val intent = Intent(BackgroundPlayerService.RELEASE_MEDIA_SESSION_BROADCAST_ACTION).apply { - setPackage(packageName) - } - sendBroadcast(intent) - } - - private fun initArguments(savedInstanceState: Bundle?) { - intent?.let { - initWithIntent(it) - } - - if (savedInstanceState == null) { - checkNotNull(file) { "Instanced with a NULL OCFile" } - checkNotNull(user) { "Instanced with a NULL ownCloud Account" } - } else { - initWithBundle(savedInstanceState) - } - } - - private fun initWithIntent(intent: Intent) { - file = intent.getParcelableArgument(FILE, OCFile::class.java) - user = intent.getParcelableArgument(USER, User::class.java) - savedPlaybackPosition = intent.getLongExtra(PLAYBACK_POSITION, 0L) - autoplay = intent.getBooleanExtra(AUTOPLAY, true) - } - - private fun initWithBundle(bundle: Bundle) { - file = bundle.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - user = bundle.getParcelableArgument(EXTRA_USER, User::class.java) - savedPlaybackPosition = bundle.getInt(EXTRA_PLAY_POSITION).toLong() - autoplay = bundle.getBoolean(EXTRA_PLAYING) - } - - private fun showMediaTypeViews() { - if (file == null) { - return - } - - exoplayerView.visibility = if (isFileVideo()) View.VISIBLE else View.GONE - binding.imagePreview.visibility = if (isFileVideo()) View.GONE else View.VISIBLE - - if (isFileVideo()) { - binding.root.setBackgroundColor(resources.getColor(R.color.black, null)) - } - } - - private fun isFileVideo(): Boolean = MimeTypeUtil.isVideo(file) - - private fun configureSystemBars() { - updateActionBarTitleAndHomeButton(file) - - supportActionBar?.let { - it.setDisplayHomeAsUpEnabled(true) - viewThemeUtils.files.themeActionBar(this, it) - - if (isFileVideo()) { - it.setTitleColor( - resources.getColor( - R.color.white, - null - ) - ) - - it.setHomeAsUpIndicator( - ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_back, theme) - ?.apply { setTint(Color.WHITE) } - ) - - it.setBackgroundDrawable(Color.BLACK.toDrawable()) - } - } - - viewThemeUtils.platform.themeStatusBar( - this - ) - } - - private fun showProgressLayout() { - binding.progress.visibility = View.VISIBLE - binding.audioControllerView.visibility = View.GONE - binding.emptyView.emptyListView.visibility = View.GONE - } - - private fun setErrorMessage(headline: String, @StringRes message: Int) { - binding.emptyView.run { - emptyListViewHeadline.text = headline - emptyListViewText.setText(message) - emptyListIcon.setImageResource(R.drawable.file_movie) - emptyListViewText.visibility = View.VISIBLE - emptyListIcon.visibility = View.VISIBLE - emptyListView.visibility = View.VISIBLE - } - - binding.progress.visibility = View.GONE - } - - private fun setGenericThumbnail() { - binding.imagePreview.setImageDrawable(genericThumbnail()) - } - - private fun genericThumbnail(): Drawable? { - val result = AppCompatResources.getDrawable(this, R.drawable.logo) - result?.let { - if (!resources.getBoolean(R.bool.is_branded_client)) { - DrawableCompat.setTint(it, resources.getColor(R.color.primary, this.theme)) - } - } - - return result - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - file.logFileSize(TAG) - outState.let { bundle -> - bundle.putParcelable(EXTRA_FILE, file) - bundle.putParcelable(EXTRA_USER, user) - saveMediaInstanceState(bundle) - } - sendShareDownloader.saveState(outState) - } - - private fun saveMediaInstanceState(bundle: Bundle) { - bundle.run { - if (MimeTypeUtil.isVideo(file)) { - videoPlayer?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - } - } else { - audioMediaController?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - } - } - putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition) - putBoolean(EXTRA_PLAYING, autoplay) - } - } - - override fun onStart() { - super.onStart() - Log_OC.v(TAG, "onStart") - if (MimeTypeUtil.isVideo(file) && videoPlayer == null) { - initializeVideoPlayer() - } - } - - private fun isFullscreenActive(): Boolean = fullscreenDialog?.isShowing == true - - private fun initializeVideoPlayer() { - lifecycleScope.launch(Dispatchers.IO) { - val client = clientRepository.getNextcloudClient() ?: return@launch - - withContext(Dispatchers.Main) { - nextcloudClient = client - videoPlayer = createNextcloudExoplayer(this@PreviewMediaActivity, client) - val uniqueSessionId = "preview_session_" + System.currentTimeMillis() - videoMediaSession = MediaSession.Builder(this@PreviewMediaActivity, videoPlayer as Player) - .setId(uniqueSessionId) - .build() - - videoPlayer?.run { - addListener( - ExoplayerListener( - this@PreviewMediaActivity, - exoplayerView, - this - ) - ) - - playVideo() - } - } - } - } - - private fun releaseVideoPlayer() { - videoPlayer?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - it.release() - videoMediaSession?.release() - } - videoMediaSession = null - videoPlayer = null - } - - @Suppress("TooGenericExceptionCaught") - private fun initializeAudioPlayer() { - val sessionToken = SessionToken(this, ComponentName(this, BackgroundPlayerService::class.java)) - mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() - mediaControllerFuture?.addListener( - { - try { - audioMediaController = mediaControllerFuture?.get() - playAudio() - binding.audioControllerView.setMediaPlayer(audioMediaController) - } catch (e: Exception) { - Log_OC.e(TAG, "exception raised while getting the media controller ${e.message}") - } - }, - MoreExecutors.directExecutor() - ) - } - - @Suppress("TooGenericExceptionCaught") - private fun playAudio() { - if (file?.isDown == true) { - prepareAudioPlayer(file?.storageUri) - } else { - try { - LoadStreamUrl(this, user, clientFactory).execute(file?.localId) - } catch (e: Exception) { - Log_OC.e(TAG, "Loading stream url for Audio not possible: $e") - } - } - } - - private fun prepareAudioPlayer(uri: Uri?) { - uri ?: return - audioMediaController?.let { audioPlayer -> - audioPlayer.addListener(object : Player.Listener { - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - if (playbackState == Player.STATE_READY) { - binding.progress.visibility = View.GONE - binding.audioControllerView.visibility = View.VISIBLE - binding.emptyView.emptyListView.visibility = View.GONE - } - } - - override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { - super.onMediaMetadataChanged(mediaMetadata) - val artworkBitmap = mediaMetadata.artworkData?.let { bytes: ByteArray -> - BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - } - if (artworkBitmap != null) { - binding.imagePreview.setImageBitmap(artworkBitmap) - } - } - - override fun onPlayerError(error: PlaybackException) { - super.onPlayerError(error) - Log_OC.e(TAG, "Exoplayer error", error) - val message = ErrorFormat.toString(this@PreviewMediaActivity, error) - MaterialAlertDialogBuilder(this@PreviewMediaActivity) - .setMessage(message) - .setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int -> - audioPlayer.seekToDefaultPosition() - audioPlayer.pause() - } - .setCancelable(false) - .show() - } - }) - val mediaItem = MediaItem.Builder() - .setUri(uri) - .setMediaMetadata(MediaMetadata.Builder().setTitle(file?.fileName).build()) - .build() - audioPlayer.setMediaItem(mediaItem) - audioPlayer.playWhenReady = autoplay - audioPlayer.seekTo(savedPlaybackPosition) - audioPlayer.prepare() - } - } - - private fun initWindowInsetsController() { - windowInsetsController = WindowCompat.getInsetsController( - window, - window.decorView - ).apply { - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - } - - private fun applyWindowInsets() { - val playerView = exoplayerView - val exoControls = playerView.findViewById(androidx.media3.ui.R.id.exo_bottom_bar) - val exoProgress = playerView.findViewById(androidx.media3.ui.R.id.exo_progress) - val progressBottomMargin = exoProgress.marginBottom - - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> - val insets = windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type - .displayCutout() - ) - - binding.materialToolbar.updateLayoutParams { - topMargin = insets.top - } - exoControls.updateLayoutParams { - bottomMargin = insets.bottom - } - exoProgress.updateLayoutParams { - bottomMargin = insets.bottom + progressBottomMargin - } - exoControls.updatePadding(left = insets.left, right = insets.right) - exoProgress.updatePadding(left = insets.left, right = insets.right) - binding.materialToolbar.updatePadding(left = insets.left, right = insets.right) - WindowInsetsCompat.CONSUMED - } - } - - private fun setupVideoView() { - initWindowInsetsController() - val type = WindowInsetsCompat.Type.systemBars() - exoplayerView.let { - it.setShowNextButton(false) - it.setShowPreviousButton(false) - it.setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visibility -> - if (visibility == View.VISIBLE) { - windowInsetsController.show(type) - supportActionBar?.show() - } else if (visibility == View.GONE) { - windowInsetsController.hide(type) - supportActionBar?.hide() - } - } - ) - it.player = videoPlayer - it.setFullscreenButton(isFullscreen = false) { startFullScreenVideo() } - } - } - - private fun startFullScreenVideo() { - val client = nextcloudClient ?: return - val player = videoPlayer ?: return - val dialog = PreviewVideoFullscreenDialog( - this, - client, - player, - exoplayerView - ) - .apply { - setOnDismissListener { - fullscreenDialog = null - setupVideoView() - } - } - - fullscreenDialog = dialog - dialog.show() - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.custom_menu_placeholder, menu) - - if (isFileVideo()) { - val moreMenuItem = menu?.findItem(R.id.custom_menu_placeholder_item) - moreMenuItem?.icon?.setTint(ContextCompat.getColor(this, R.color.white)) - } - - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - - if (item.itemId == R.id.custom_menu_placeholder_item) { - onOverflowClick() - } - - return super.onOptionsItemSelected(item) - } - - /** - * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails - */ - private fun onOverflowClick(isManualClick: Boolean = false) { - val file = file - if (storageManager != null && file != null) { - val updatedFile = storageManager.getFileById(file.fileId) - // check for albums file - // for album file both local and remoteId will be same configured at operation level - if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { - fetchFileMetaDataIfAbsent(updatedFile) - } else { - setFile(updatedFile) - val fileNew = getFile() - fileNew?.let { showFileActions(it) } - } - } - } - - private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { - showLoadingDialog(getString(R.string.wait_a_moment)) - lifecycleScope.launch(Dispatchers.IO) { - val operation = FetchRemoteFileOperation( - this@PreviewMediaActivity, - accountManager.user, - ocFile, - removeFileFromDb = true, - storageManager = storageManager - ) - val result = operation.execute(this@PreviewMediaActivity) - - withContext(Dispatchers.Main) { - dismissLoadingDialog() - if (result?.isSuccess == true && result.resultData != null) { - file = result.resultData as OCFile - onOverflowClick(isManualClick = true) - } else { - Log_OC.d(TAG, result?.logMessage) - DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(this@PreviewMediaActivity)) - } - } - } - } - - private fun showFileActions(file: OCFile) { - val additionalFilter = FileAction.getFilePreviewActions(getFile()) - newInstance(file, false, additionalFilter) - .setResultListener( - supportFragmentManager, - this, - object : ResultListener { - override fun onResult(actionId: Int) { - onFileActionChosen(actionId) - } - } - ) - .show(supportFragmentManager, "actions") - } - - private fun onFileActionChosen(itemId: Int) { - when (itemId) { - R.id.action_send_share_file -> { - sendShareFile(null) - } - - R.id.action_send_file -> { - sendShareFile(true) - } - - R.id.action_open_file_with -> { - openFile() - } - - R.id.action_remove_file -> { - videoPlayer?.pause() - val dialog = file?.let { RemoveFilesDialogFragment.newInstance(it) } - dialog?.show(supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) - } - - R.id.action_see_details -> { - seeDetails() - } - - R.id.action_sync_file -> { - showSyncLoadingDialog(file?.isFolder == true) - fileOperationsHelper.syncFileOrFolder(file) - } - - R.id.action_cancel_sync -> { - fileOperationsHelper.cancelTransference(file) - } - - R.id.action_stream_media -> { - fileOperationsHelper.streamMediaFile(file) - } - - R.id.action_export_file -> { - val list = ArrayList() - file?.let { list.add(it) } - fileOperationsHelper.exportFiles( - list, - this, - binding.root, - this.backgroundJobManager - ) - } - - R.id.action_download_file -> { - requestForDownload(file) - } - - R.id.action_add_to_album -> { - file?.let { - fileOperationsHelper.addFileToAlbum(listOf(it)) - } - } - } - } - - override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>?) { - super.onRemoteOperationFinish(operation, result) - if (operation is RemoveFileOperation) { - if (result?.isSuccess == false) { - val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(result, operation, resources) - DisplayUtils.showSnackMessage(this, errorMessage) - } - - val removedFile = operation.file - val fileAvailable: Boolean = storageManager.fileExists(removedFile.fileId) - if (!fileAvailable && removedFile == file) { - sendAudioSessionReleaseBroadcast() - finish() - } - } else if (operation is SynchronizeFileOperation) { - onSynchronizeFileOperationFinish(result) - } else if (operation is CopyFileToAlbumOperation) { - onCopyAlbumFileOperationFinish(operation, result) - } - } - - private fun onSynchronizeFileOperationFinish(result: RemoteOperationResult<*>?) { - result?.let { - invalidateOptionsMenu() - } - } - - private fun onCopyAlbumFileOperationFinish(operation: CopyFileToAlbumOperation, result: RemoteOperationResult<*>?) { - if (result?.isSuccess == true) { - DisplayUtils.showSnackMessage(this, getString(R.string.album_file_added_message)) - return - } - - try { - DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, resources)) - } catch (e: Resources.NotFoundException) { - Log_OC.e(TAG, "Error while trying to show fail message ", e) - } - } - - override fun downloadFile(file: OCFile, packageName: String, activityName: String) { - sendShareDownloader.downloadFile(file, packageName, activityName) - } - - private fun requestForDownload(file: OCFile?) { - val fileDownloadHelper = FileDownloadHelper.instance() - - if (fileDownloadHelper.isDownloading(user, file)) { - return - } - - user?.let { user -> - file?.let { file -> - fileDownloadHelper.downloadFile(user, file, downloadType = DownloadType.DOWNLOAD) - } - } - } - - private fun seeDetails() { - stopPreview(false) - showDetails(file) - } - - private fun sendShareFile(hideNCSharingOption: Boolean?) { - stopPreview(false) - - if (hideNCSharingOption != null) { - fileOperationsHelper.sendShareFile(file, hideNCSharingOption) - } else { - fileOperationsHelper.sendShareFile(file) - } - } - - @Suppress("TooGenericExceptionCaught") - private fun playVideo() { - setupVideoView() - - if (file?.isDown == true) { - prepareVideoPlayer(file?.storageUri) - } else { - try { - LoadStreamUrl(this, user, clientFactory).execute(file?.localId) - } catch (e: Exception) { - Log_OC.e(TAG, "Loading stream url for Video not possible: $e") - } - } - } - - private fun prepareVideoPlayer(uri: Uri?) { - uri ?: return - binding.progress.visibility = View.GONE - val videoMediaItem = MediaItem.fromUri(uri) - videoPlayer?.run { - setMediaItem(videoMediaItem) - playWhenReady = autoplay - seekTo(savedPlaybackPosition) - prepare() - } - } - - private class LoadStreamUrl( - previewMediaActivity: PreviewMediaActivity, - private val user: User?, - private val clientFactory: ClientFactory? - ) : AsyncTask() { - private val previewMediaActivityWeakReference: WeakReference = - WeakReference(previewMediaActivity) - - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg fileId: Long?): Uri? { - val client: OwnCloudClient? = try { - clientFactory?.create(user) - } catch (e: CreationException) { - Log_OC.e(TAG, "Loading stream url not possible: $e") - return null - } - - val sfo = StreamMediaFileOperation(fileId[0]!!) - val result = sfo.execute(client) - - return if (!result.isSuccess) { - null - } else { - (result.data[0] as String).toUri() - } - } - - @Deprecated("Deprecated in Java") - override fun onPostExecute(uri: Uri?) { - val weakReference = previewMediaActivityWeakReference.get() - weakReference?.apply { - if (uri != null) { - streamUri = uri - if (MimeTypeUtil.isVideo(file)) { - prepareVideoPlayer(uri) - } else if (MimeTypeUtil.isAudio(file)) { - prepareAudioPlayer(uri) - } - } else { - emptyListView?.visibility = View.VISIBLE - setErrorMessage( - weakReference.getString(R.string.stream_not_possible_headline), - R.string.stream_not_possible_message - ) - } - } - } - } - - override fun onPause() { - Log_OC.v(TAG, "onPause") - - super.onPause() - } - - override fun onResume() { - super.onResume() - - Log_OC.v(TAG, "onResume") - } - - override fun onDestroy() { - mediaControllerFuture?.let { MediaController.releaseFuture(it) } - releaseVideoPlayer() - super.onDestroy() - - Log_OC.v(TAG, "onDestroy") - } - - override fun onStop() { - Log_OC.v(TAG, "onStop") - if (!isFullscreenActive()) { - releaseVideoPlayer() - } - super.onStop() - } - - override fun showDetails(file: OCFile?) { - val intent = Intent(this, FileDisplayActivity::class.java).apply { - action = FileDisplayActivity.ACTION_DETAILS - putExtra(FileActivity.EXTRA_FILE, file) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - startActivity(intent) - finish() - } - - override fun showDetails(file: OCFile?, activeTab: Int) { - showDetails(file) - } - - override fun onBrowsedDownTo(folder: OCFile?) = Unit - override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) = Unit - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - Log_OC.v(TAG, "onConfigurationChanged $this") - } - - @Suppress("DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - Log_OC.v(TAG, "onActivityResult $this") - super.onActivityResult(requestCode, resultCode, data) - - if (resultCode == RESULT_OK) { - savedPlaybackPosition = data?.getLongExtra(EXTRA_START_POSITION, 0) ?: 0 - autoplay = data?.getBooleanExtra(EXTRA_AUTOPLAY, false) ?: false - } - } - - private fun openFile() { - stopPreview(true) - fileOperationsHelper.openFile(file) - } - - private fun stopPreview(stopAudio: Boolean) { - if (MimeTypeUtil.isAudio(file) && stopAudio) { - audioMediaController?.pause() - } else if (MimeTypeUtil.isVideo(file)) { - releaseVideoPlayer() - } - } - - companion object { - private val TAG = PreviewMediaActivity::class.java.simpleName - - const val MEDIA_CONTROL_READY_RECEIVER: String = "MEDIA_CONTROL_READY_RECEIVER" - const val EXTRA_FILE = "FILE" - const val EXTRA_USER = "USER" - const val EXTRA_AUTOPLAY = "AUTOPLAY" - const val EXTRA_START_POSITION = "START_POSITION" - private const val EXTRA_PLAY_POSITION = "PLAY_POSITION" - private const val EXTRA_PLAYING = "PLAYING" - private const val FILE = "FILE" - private const val USER = "USER" - private const val PLAYBACK_POSITION = "PLAYBACK_POSITION" - private const val AUTOPLAY = "AUTOPLAY" - - fun canBePreviewed(file: OCFile?): Boolean = - file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) - } -} From f60bd6291cbf4225cb10407ae6088f07f0808f9c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 14:59:01 +0200 Subject: [PATCH 22/69] wip Signed-off-by: alperozturk96 --- .../player/media3/Media3PlaybackModelTest.kt | 272 --------------- .../player/media3/PlaybackStateFactoryTest.kt | 97 ------ .../media3/common/MediaItemFactoryTest.kt | 41 --- .../client/player/media3/common/TestPlayer.kt | 163 --------- .../player/media3/common/TestPlayerFactory.kt | 16 - .../player/media3/common/TestPlayerTest.kt | 141 -------- .../controller/TestMediaControllerFactory.kt | 26 -- .../datasource/DefaultDataSourceTest.kt | 132 -------- .../PlaybackResumptionConfigStoreTest.kt | 56 ---- .../PlaybackResumptionLauncherTest.kt | 141 -------- .../PlaybackResumptionPlayerListenerTest.kt | 39 --- .../session/MediaSessionBitmapLoaderTest.kt | 119 ------- .../media3/session/TestMediaSessionFactory.kt | 24 -- .../player/model/PlaybackSettingsTest.kt | 65 ---- .../model/file/PlaybackFileMapperTest.kt | 57 ---- .../model/file/PlaybackFileUriMapperTest.kt | 47 --- .../model/file/PlaybackFilesRepositoryTest.kt | 312 ------------------ .../ui/control/PlayerControlViewTest.kt | 175 ---------- 18 files changed, 1923 deletions(-) delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt delete mode 100644 app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt deleted file mode 100644 index 7ea1f8f299bb..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/Media3PlaybackModelTest.kt +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3 - -import android.content.Context -import androidx.test.annotation.UiThreadTest -import androidx.test.core.app.ApplicationProvider -import com.nextcloud.client.player.media3.common.MediaItemFactory -import com.nextcloud.client.player.media3.common.TestPlayerFactory -import com.nextcloud.client.player.media3.controller.TestMediaControllerFactory -import com.nextcloud.client.player.media3.session.MediaSessionHolder -import com.nextcloud.client.player.media3.session.TestMediaSessionFactory -import com.nextcloud.client.player.model.PlaybackModel -import com.nextcloud.client.player.model.PlaybackSettings -import com.nextcloud.client.player.model.error.DefaultPlaybackErrorStrategy -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.file.PlaybackFiles -import com.nextcloud.client.player.model.file.PlaybackFilesComparator -import com.nextcloud.client.player.model.state.PlayerState -import com.nextcloud.client.player.model.state.RepeatMode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -@Suppress("TooManyFunctions") -class Media3PlaybackModelTest { - private lateinit var settings: PlaybackSettings - private lateinit var model: PlaybackModel - - private val testDispatcher = StandardTestDispatcher() - - @Before - @UiThreadTest - fun setup() { - val context: Context = ApplicationProvider.getApplicationContext() - val playerFactory = TestPlayerFactory() - val sessionFactory = TestMediaSessionFactory(context, playerFactory) - - settings = PlaybackSettings(context) - - model = Media3PlaybackModel( - PlaybackStateFactory(), - sessionFactory, - TestMediaControllerFactory(context) { model as MediaSessionHolder }, - settings, - MediaItemFactory(), - DefaultPlaybackErrorStrategy() - ) - - Dispatchers.setMain(testDispatcher) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - @UiThreadTest - fun start_initialState() = runModelTest { model -> - model.state.get().let { state -> - assertTrue(state.currentFiles.isEmpty()) - assertNull(state.currentItemState) - assertEquals(settings.repeatMode, state.repeatMode) - assertEquals(settings.isShuffle, state.shuffle) - } - } - - @Test - @UiThreadTest - fun setFiles_initialQueue() = runModelTest { model -> - val inputFiles = playbackFiles(3) - - model.setFiles(inputFiles) - - model.state.get().let { state -> - assertEquals(3, state.currentFiles.size) - assertNotEquals(PlayerState.PLAYING, state.currentItemState!!.playerState) - } - - model.play() - - model.state.get().let { state -> - assertEquals(PlayerState.PLAYING, state.currentItemState!!.playerState) - assertEquals(inputFiles.list.first().id, state.currentItemState.file.id) - } - } - - @Test - @UiThreadTest - fun setFiles_updateRetainCurrentItem() = runModelTest { model -> - val inputFiles = playbackFiles(3) - val initialFileId = inputFiles.list.first().id - - model.setFiles(inputFiles) - model.play() - - model.state.get().let { state -> - assertEquals(3, state.currentFiles.size) - assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) - assertEquals(initialFileId, state.currentItemState.file.id) - } - - model.setFiles(inputFiles.copy(list = listOf(file(99)) + inputFiles.list)) - - model.state.get().let { state -> - assertEquals(4, state.currentFiles.size) - assertEquals(1, state.currentFiles.indexOf(state.currentItemState!!.file)) - assertEquals(initialFileId, state.currentItemState.file.id) - } - } - - @Test - @UiThreadTest - fun setFiles_repositionWhenCurrentRemoved() = runModelTest { model -> - val inputFiles = playbackFiles(3) - val initialFileId = inputFiles.list.first().id - - model.setFiles(inputFiles) - model.play() - - model.state.get().let { state -> - assertEquals(3, state.currentFiles.size) - assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) - assertEquals(initialFileId, state.currentItemState.file.id) - } - - model.setFiles(inputFiles.copy(list = inputFiles.list.filter { it.id != initialFileId })) - - model.state.get().let { state -> - assertEquals(2, state.currentFiles.size) - assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) - assertNotEquals(initialFileId, state.currentItemState.file.id) - } - } - - @Test - @UiThreadTest - fun playback_playPauseStop() = runModelTest { model -> - model.setFiles(playbackFiles(2)) - - model.play() - assertEquals(PlayerState.PLAYING, model.state.get().currentItemState!!.playerState) - - model.pause() - assertEquals(PlayerState.PAUSED, model.state.get().currentItemState!!.playerState) - } - - @Test - @UiThreadTest - fun playback_nextPrevious() = runModelTest { model -> - model.setFiles(playbackFiles(3)) - - model.play() - model.state.get().let { state -> - assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) - } - - model.playNext() - model.state.get().let { state -> - assertEquals(1, state.currentFiles.indexOf(state.currentItemState!!.file)) - } - - model.playPrevious() - model.state.get().let { state -> - assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) - } - } - - @Test - @UiThreadTest - fun playback_seekToPosition() = runModelTest { model -> - model.setFiles(playbackFiles(1)) - model.seekToPosition(5000L) - model.play() - assertEquals(5000L, model.state.get().currentItemState!!.currentTimeInMilliseconds) - } - - @Test - @UiThreadTest - fun settings_repeatAndShuffle() = runModelTest { model -> - model.setFiles(playbackFiles(2)) - model.setRepeatMode(RepeatMode.ALL) - model.setShuffle(true) - - model.state.get().let { state -> - assertEquals(RepeatMode.ALL, state.repeatMode) - assertTrue(state.shuffle) - } - - model.setRepeatMode(RepeatMode.SINGLE) - model.setShuffle(false) - - model.state.get().let { state -> - assertEquals(RepeatMode.SINGLE, state.repeatMode) - assertFalse(state.shuffle) - } - } - - @Test - @UiThreadTest - fun switchToFile_changesCurrentItem() = runModelTest { model -> - val inputFiles = playbackFiles(4) - - model.setFiles(inputFiles) - model.play() - - model.state.get().let { state -> - assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file)) - assertEquals(inputFiles.list.first().id, state.currentItemState.file.id) - } - - model.switchToFile(inputFiles.list.last()) - - model.state.get().let { state -> - assertEquals(3, state.currentFiles.indexOf(state.currentItemState!!.file)) - assertEquals(inputFiles.list.last().id, state.currentItemState.file.id) - } - } - - @Test - @UiThreadTest - fun setFilesFlow_emitsAndUpdates() = runModelTest { model -> - val filesFlow = MutableStateFlow(playbackFiles(2)) - model.setFilesFlow(filesFlow) - testDispatcher.scheduler.advanceUntilIdle() - assertEquals(2, model.state.get().currentFiles.size) - - filesFlow.tryEmit(playbackFiles(3)) - testDispatcher.scheduler.advanceUntilIdle() - assertEquals(3, model.state.get().currentFiles.size) - } - - private fun runModelTest(test: suspend TestScope.(PlaybackModel) -> Unit) = runTest { - model.start() - test(model) - model.release() - settings.reset() - } - - private fun file(id: Int) = PlaybackFile( - id = "id$id", - uri = "https://example.com/media$id.mp3", - name = "media$id.mp3", - mimeType = "audio/mpeg", - contentLength = 123456, - lastModified = System.currentTimeMillis() + id, - isFavorite = false - ) - - private fun playbackFiles(count: Int): PlaybackFiles { - val list = (0 until count).map { file(it) } - return PlaybackFiles(list, PlaybackFilesComparator.NONE) - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt deleted file mode 100644 index 095ab34aa9b4..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/PlaybackStateFactoryTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3 - -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player -import com.nextcloud.client.player.media3.common.MediaItemFactory -import com.nextcloud.client.player.media3.common.setExtras -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.state.PlayerState -import com.nextcloud.client.player.model.state.RepeatMode -import io.mockk.every -import io.mockk.mockk -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import androidx.media3.common.VideoSize as ExoVideoSize - -class PlaybackStateFactoryTest { - - private val stateFactory = PlaybackStateFactory() - private val itemFactory = MediaItemFactory() - - @Test - fun create_builds_PlaybackState_with_expected_fields() { - val player = mockk(relaxed = true) - - val file1 = createPlaybackFile("1") - val file2 = createPlaybackFile("2") - val item1 = itemFactory.create(file1) - val item2 = itemFactory.create(file2) - - val playerMetadata = MediaMetadata.Builder() - .setTitle("") // force fallback to file name without extension - .setArtist("Artist") - .setAlbumTitle("Album") - .setGenre("Rock") - .setRecordingYear(1999) - .setDescription("Desc") - .setArtworkData(byteArrayOf(1, 2, 3), 0) - .setExtras(file2) - .build() - - every { player.mediaItemCount } returns 2 - every { player.getMediaItemAt(0) } returns item1 - every { player.getMediaItemAt(1) } returns item2 - every { player.shuffleModeEnabled } returns true - every { player.repeatMode } returns Player.REPEAT_MODE_ALL - every { player.currentMediaItem } returns item2 - every { player.mediaMetadata } returns playerMetadata - every { player.currentPosition } returns 12_345L - every { player.duration } returns 54_321L - every { player.videoSize } returns ExoVideoSize(1920, 1080) - every { player.playbackState } returns Player.STATE_READY - every { player.playWhenReady } returns false - - val state = stateFactory.create(player).orElseThrow() - val currentItemState = state.currentItemState!! - val currentItemMetadata = currentItemState.metadata!! - val currentItemVideoSize = currentItemState.videoSize!! - - assertEquals(listOf(file1, file2), state.currentFiles) - assertEquals(RepeatMode.ALL, state.repeatMode) - assertTrue(state.shuffle) - assertEquals(file2, currentItemState.file) - assertEquals(PlayerState.PAUSED, currentItemState.playerState) - assertEquals("name2", currentItemMetadata.title) // fallback from empty title - assertEquals("Artist", currentItemMetadata.artist) - assertEquals("Album", currentItemMetadata.album) - assertEquals("Rock", currentItemMetadata.genre) - assertEquals(1999, currentItemMetadata.year) - assertEquals("Desc", currentItemMetadata.description) - assertArrayEquals(byteArrayOf(1, 2, 3), currentItemMetadata.artworkData) - assertNull(currentItemMetadata.artworkUri) - assertEquals(1920, currentItemVideoSize.width) - assertEquals(1080, currentItemVideoSize.height) - assertEquals(12_345L, currentItemState.currentTimeInMilliseconds) - assertEquals(54_321L, currentItemState.maxTimeInMilliseconds) - } - - private fun createPlaybackFile(id: String) = PlaybackFile( - id = id, - uri = "uri$id", - name = "name$id.mp3", - mimeType = "audio/mpeg", - contentLength = 0, - lastModified = 0, - isFavorite = false - ) -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt deleted file mode 100644 index f9703a4fca2f..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/MediaItemFactoryTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.common - -import com.nextcloud.client.player.model.file.PlaybackFile -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test - -class MediaItemFactoryTest { - - private val factory = MediaItemFactory() - - @Test - fun create_builds_MediaItem_with_expected_fields() { - val file = PlaybackFile( - id = "123", - uri = "https://example.com/media.mp3", - name = "media.mp3", - mimeType = "audio/mpeg", - contentLength = 42L, - lastModified = 1736200000000L, - isFavorite = true - ) - - val item = factory.create(file) - - assertEquals(file.id, item.mediaId) - assertEquals(file.uri, item.localConfiguration?.uri.toString()) - assertEquals(file.mimeType, item.localConfiguration?.mimeType) - - val metadata = item.mediaMetadata - assertNotNull(metadata) - assertEquals(file, metadata.playbackFile) - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt deleted file mode 100644 index 32706605cfad..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayer.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.common - -import android.os.Looper -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.SimpleBasePlayer -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture - -class TestPlayer(looper: Looper) : SimpleBasePlayer(looper) { - private val mediaItems = mutableListOf() - private var currentIndex = 0 - private var isPlaying = false - private var currentPositionMs: Long = 0L - private var repeatModeInternal: Int = REPEAT_MODE_OFF - private var shuffleEnabled: Boolean = false - - override fun getState(): State { - val commands = Player.Commands.Builder() - .addAllCommands() - .build() - - return State.Builder() - .setAvailableCommands(commands) - .setPlaybackState(if (isPlaying) STATE_READY else STATE_IDLE) - .setPlayWhenReady(isPlaying, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) - .setPlaylist(mediaItems.map { MediaItemData.Builder(it.mediaId).setMediaItem(it).build() }) - .setCurrentMediaItemIndex(currentIndex) - .setContentPositionMs(currentPositionMs) - .setRepeatMode(repeatModeInternal) - .setShuffleModeEnabled(shuffleEnabled) - .build() - } - - override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { - isPlaying = playWhenReady - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleStop(): ListenableFuture<*> { - isPlaying = false - currentPositionMs = 0L - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handlePrepare(): ListenableFuture<*> { - currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - currentPositionMs = 0L - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleRelease(): ListenableFuture<*> { - isPlaying = false - mediaItems.clear() - currentIndex = 0 - currentPositionMs = 0L - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> { - repeatModeInternal = repeatMode - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> { - shuffleEnabled = shuffleModeEnabled - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleSetMediaItems( - items: List, - startIndex: Int, - startPositionMs: Long - ): ListenableFuture<*> { - mediaItems.clear() - mediaItems.addAll(items) - currentIndex = if (startIndex != C.INDEX_UNSET) startIndex else 0 - currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - currentPositionMs = if (startPositionMs != C.TIME_UNSET) startPositionMs else 0L - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleAddMediaItems(index: Int, newItems: List): ListenableFuture<*> { - mediaItems.addAll(index, newItems) - if (index <= currentIndex) { - currentIndex += newItems.size - } - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleMoveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int): ListenableFuture<*> { - val movingItems = mediaItems.subList(fromIndex, toIndex).toList() - mediaItems.subList(fromIndex, toIndex).clear() - mediaItems.addAll(newIndex, movingItems) - - currentIndex = when { - currentIndex in fromIndex until toIndex -> newIndex + (currentIndex - fromIndex) - currentIndex < fromIndex && newIndex <= currentIndex -> currentIndex + movingItems.size - currentIndex >= toIndex && newIndex < currentIndex -> currentIndex - movingItems.size - else -> currentIndex - } - currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleReplaceMediaItems(fromIndex: Int, toIndex: Int, newItems: List): ListenableFuture<*> { - mediaItems.subList(fromIndex, toIndex).clear() - mediaItems.addAll(fromIndex, newItems) - - if (currentIndex in fromIndex until toIndex) { - currentIndex = fromIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - currentPositionMs = 0L - } else if (currentIndex >= toIndex) { - currentIndex += (newItems.size - (toIndex - fromIndex)) - } - - currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleRemoveMediaItems(fromIndex: Int, toIndex: Int): ListenableFuture<*> { - mediaItems.subList(fromIndex, toIndex).clear() - - if (currentIndex in fromIndex until toIndex) { - currentIndex = fromIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - currentPositionMs = 0L - } else if (currentIndex >= toIndex) { - currentIndex -= (toIndex - fromIndex) - } - - currentIndex = currentIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - invalidateState() - return Futures.immediateVoidFuture() - } - - override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { - if (mediaItemIndex != C.INDEX_UNSET) { - currentIndex = mediaItemIndex.coerceAtMost(mediaItems.lastIndex.coerceAtLeast(0)) - } - currentPositionMs = if (positionMs != C.TIME_UNSET) positionMs else 0L - invalidateState() - return Futures.immediateVoidFuture() - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt deleted file mode 100644 index 19b287a7a8da..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerFactory.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.common - -import android.os.Looper -import androidx.media3.common.Player - -class TestPlayerFactory : PlayerFactory { - - override fun create(): Player = TestPlayer(Looper.getMainLooper()) -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt deleted file mode 100644 index 066ee119abc8..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/common/TestPlayerTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.common - -import android.os.Looper -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.test.annotation.UiThreadTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class TestPlayerTest { - - private lateinit var player: TestPlayer - - @Before - fun setUp() { - player = TestPlayer(Looper.getMainLooper()) - } - - @Test - @UiThreadTest - fun setMediaItems_applies_startIndex_and_startPosition_bounds() { - player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 2_000) - assertEquals(1, player.currentMediaItemIndex) - assertEquals(2_000L, player.currentPosition) - assertEquals(3, player.mediaItemCount) - } - - @Test - @UiThreadTest - fun play_pause_toggles_state() { - player.setMediaItems(listOf(item("a"))) - assertFalse(player.playWhenReady) - - player.play() - assertTrue(player.playWhenReady) - - player.pause() - assertFalse(player.playWhenReady) - } - - @Test - @UiThreadTest - fun addMediaItems_updates_indices_when_inserted_before_current() { - player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 0) - assertEquals(1, player.currentMediaItemIndex) - assertEquals("b", player.currentMediaItem?.mediaId) - - player.addMediaItems(0, listOf(item("x"), item("y"))) - assertEquals(3, player.currentMediaItemIndex) - assertEquals("b", player.currentMediaItem?.mediaId) - } - - @Test - @UiThreadTest - fun moveMediaItems_recomputes_current_index_inside_moved_block() { - player.setMediaItems(listOf(item("a"), item("b"), item("c"), item("d")), 2, 0) - assertEquals(2, player.currentMediaItemIndex) - assertEquals("c", player.currentMediaItem?.mediaId) - - player.moveMediaItems(1, 3, 2) - assertEquals(3, player.currentMediaItemIndex) - assertEquals("c", player.currentMediaItem?.mediaId) - } - - @Test - @UiThreadTest - fun replaceMediaItems_resets_position_when_current_replaced() { - player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 1_000) - player.replaceMediaItems(1, 2, listOf(item("x"), item("y"))) - assertEquals(1, player.currentMediaItemIndex) - assertEquals("x", player.currentMediaItem?.mediaId) - assertEquals(0L, player.currentPosition) - } - - @Test - @UiThreadTest - fun removeMediaItems_updates_current_index_and_resets_if_removed() { - player.setMediaItems(listOf(item("a"), item("b"), item("c")), 1, 500) - player.removeMediaItems(1, 2) - assertEquals(1, player.currentMediaItemIndex) - assertEquals("c", player.currentMediaItem?.mediaId) - assertEquals(0L, player.currentPosition) - } - - @Test - @UiThreadTest - fun seekTo_updates_index_and_position() { - player.setMediaItems(listOf(item("a"), item("b"), item("c")), 0, 0) - player.seekTo(2, 12_345L) - assertEquals(2, player.currentMediaItemIndex) - assertEquals(12_345L, player.currentPosition) - } - - @Test - @UiThreadTest - fun shuffle_and_repeat_flags_reflected_in_state() { - player.setMediaItems(listOf(item("a"), item("b"))) - player.setShuffleModeEnabled(true) - player.repeatMode = Player.REPEAT_MODE_ALL - assertTrue(player.shuffleModeEnabled) - assertEquals(Player.REPEAT_MODE_ALL, player.repeatMode) - } - - @Test - @UiThreadTest - fun stop_and_release_clear_state() { - player.setMediaItems(listOf(item("a"), item("b")), 1, 100) - player.play() - player.stop() - assertEquals(0L, player.currentPosition) - assertFalse(player.playWhenReady) - - player.release() - assertEquals(0, player.mediaItemCount) - assertEquals(0, player.currentMediaItemIndex) - } - - @Test - @UiThreadTest - fun unset_start_values_use_defaults() { - player.setMediaItems(listOf(item("a"), item("b")), C.INDEX_UNSET, C.TIME_UNSET) - assertEquals(0, player.currentMediaItemIndex) - assertEquals(0L, player.currentPosition) - } - - private fun item(id: String) = MediaItem.Builder() - .setMediaId(id) - .setUri("https://example.com/$id.mp3") - .build() -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt deleted file mode 100644 index 18ad0e06f970..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/controller/TestMediaControllerFactory.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.controller - -import android.content.Context -import androidx.media3.session.MediaController -import com.nextcloud.client.player.media3.session.MediaSessionHolder -import kotlinx.coroutines.guava.await -import javax.inject.Provider - -class TestMediaControllerFactory( - private val context: Context, - private val sessionHolder: Provider -) : MediaControllerFactory { - - override suspend fun create(controllerListener: MediaController.Listener): MediaController = - MediaController.Builder(context, sessionHolder.get().getMediaSession().token) - .setListener(controllerListener) - .buildAsync() - .await() -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt deleted file mode 100644 index b82fb1a85a72..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.datasource - -import android.net.Uri -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSpec -import com.nextcloud.client.player.model.file.getPlaybackUri -import com.owncloud.android.datamodel.FileDataStorageManager -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.files.StreamMediaFileOperation -import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import io.mockk.confirmVerified -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import io.mockk.verifySequence -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import java.io.File -import java.io.IOException - -class DefaultDataSourceTest { - - private val delegate = mockk() - private val fileStore = mockk() - private val client = mockk() - private val streamOperationFactory = mockk() - - private lateinit var dataSource: DefaultDataSource - - @Before - fun setup() { - every { delegate.responseHeaders } returns emptyMap() - dataSource = DefaultDataSource(delegate, fileStore, client, streamOperationFactory) - } - - @Test - fun open_pass_through_when_uri_is_not_remote_file() { - val spec = DataSpec.Builder().setUri(Uri.parse("https://example.com/a.mp3")).build() - every { delegate.open(spec) } returns 123L - - val bytes = dataSource.open(spec) - - Assert.assertEquals(123L, bytes) - verify(exactly = 1) { delegate.open(spec) } - confirmVerified(delegate) - } - - @Test - fun open_opens_local_file_when_file_is_downloaded() { - val id = 42L - val tempFile = File.createTempFile("test_media", ".mp3") - val ocFile = OCFile("/remote/path/file.mp3").apply { - localId = id - setStoragePath(tempFile.absolutePath) - mimeType = "audio/mpeg" - } - - assert(tempFile.exists()) - - every { fileStore.getFileByLocalId(id) } returns ocFile - every { delegate.open(any()) } returns 555L - - val bytes = dataSource.open(remoteFileSpec(id)) - - Assert.assertEquals(555L, bytes) - verify { fileStore.getFileByLocalId(id) } - verify { delegate.open(match { it.uri == ocFile.storageUri }) } - confirmVerified(fileStore, delegate) - } - - @Test - fun open_opens_remote_stream_when_file_not_downloaded() { - val id = 7L - every { fileStore.getFileByLocalId(id) } returns null - - val streamOperation = mockk() - every { streamOperationFactory.create(id) } returns streamOperation - - val result = RemoteOperationResult>(RemoteOperationResult.ResultCode.OK) - result.data = arrayListOf("https://stream/url.m3u8") - every { streamOperation.execute(client) } returns result - every { delegate.open(any()) } returns 777L - - val bytes = dataSource.open(remoteFileSpec(id)) - - Assert.assertEquals(777L, bytes) - verifySequence { - fileStore.getFileByLocalId(id) - streamOperationFactory.create(id) - streamOperation.execute(client) - delegate.open(match { it.uri.toString() == "https://stream/url.m3u8" }) - } - } - - @Test - fun open_throws_IOException_when_remote_operation_fails() { - val id = 9L - every { fileStore.getFileByLocalId(id) } returns null - - val streamOperation = mockk() - every { streamOperationFactory.create(id) } returns streamOperation - - val result = mockk>>() - every { result.isSuccess } returns false - every { result.exception } returns RuntimeException("boom") - every { streamOperation.execute(client) } returns result - - Assert.assertThrows(IOException::class.java) { - dataSource.open(remoteFileSpec(id)) - } - - verify { - fileStore.getFileByLocalId(id) - streamOperationFactory.create(id) - streamOperation.execute(client) - } - verify(exactly = 0) { delegate.open(any()) } - } - - private fun remoteFileSpec(id: Long) = DataSpec.Builder() - .setUri(getPlaybackUri(id)) - .build() -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt deleted file mode 100644 index e69eef68bcc2..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStoreTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.resumption - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import com.nextcloud.client.player.model.file.PlaybackFileType -import com.owncloud.android.ui.fragment.SearchType -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test - -class PlaybackResumptionConfigStoreTest { - - private lateinit var store: PlaybackResumptionConfigStore - - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - store = PlaybackResumptionConfigStore(context) - } - - @Test - fun save_and_load_returns_expected_config() { - store.saveConfig("123", 42L, PlaybackFileType.AUDIO, SearchType.NO_SEARCH) - val loaded = store.loadConfig() - requireNotNull(loaded) - assertEquals("123", loaded.currentFileId) - assertEquals(42L, loaded.folderId) - assertEquals(PlaybackFileType.AUDIO, loaded.fileType) - assertEquals(SearchType.NO_SEARCH, loaded.searchType) - } - - @Test - fun clear_and_load_returns_null() { - store.saveConfig("123", 42L, PlaybackFileType.AUDIO, SearchType.NO_SEARCH) - store.clear() - assertNull(store.loadConfig()) - } - - @Test - fun updateCurrentFileId_only_changes_that() { - store.saveConfig("123", 42L, PlaybackFileType.AUDIO, SearchType.NO_SEARCH) - store.updateCurrentFileId("999") - val loaded = store.loadConfig() - requireNotNull(loaded) - assertEquals("999", loaded.currentFileId) - assertEquals(42L, loaded.folderId) - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt deleted file mode 100644 index a8dbb781e0ce..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncherTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.resumption - -import androidx.media3.common.MediaItem -import com.nextcloud.client.player.media3.common.MediaItemFactory -import com.nextcloud.client.player.model.PlaybackModel -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.file.PlaybackFileType -import com.nextcloud.client.player.model.file.PlaybackFiles -import com.nextcloud.client.player.model.file.PlaybackFilesComparator -import com.nextcloud.client.player.model.file.PlaybackFilesRepository -import com.owncloud.android.ui.fragment.SearchType -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Before -import org.junit.Test - -class PlaybackResumptionLauncherTest { - private val configStore = mockk() - private val filesRepository = mockk() - private val mediaItemFactory = mockk() - private val playbackModel = mockk(relaxed = true) - - private val testDispatcher = StandardTestDispatcher() - - private lateinit var launcher: PlaybackResumptionLauncher - - @Before - fun setUp() { - Dispatchers.setMain(testDispatcher) - coEvery { playbackModel.start() } just Runs - - launcher = PlaybackResumptionLauncher( - playbackResumptionConfigStore = configStore, - playbackFilesRepository = filesRepository, - mediaItemFactory = mediaItemFactory, - playbackModel = playbackModel - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun launch_success_uses_config_and_sets_files_flow() = runTest { - val currentFileId = "2" - val config = PlaybackResumptionConfig( - currentFileId = currentFileId, - folderId = 42L, - fileType = PlaybackFileType.AUDIO, - searchType = SearchType.FAVORITE_SEARCH - ) - - every { configStore.loadConfig() } returns config - - val file1 = PlaybackFile("1", "uri1", "n1", "audio/mpeg", 10, 0, false) - val file2 = PlaybackFile("2", "uri2", "n2", "audio/mpeg", 11, 0, false) - val file3 = PlaybackFile("3", "uri3", "n3", "audio/mpeg", 12, 0, false) - - val firstEmission = PlaybackFiles(listOf(file1, file2, file3), PlaybackFilesComparator.FAVORITE) - val secondEmission = PlaybackFiles(listOf(file2, file3), PlaybackFilesComparator.FAVORITE) - - every { - filesRepository.observe(config.folderId, config.fileType, config.searchType) - } returns flow { - emit(firstEmission) - emit(secondEmission) - } - - listOf(file1, file2, file3, file2, file3).forEach { file -> - every { mediaItemFactory.create(file) } returns MediaItem.Builder() - .setMediaId(file.id) - .setUri(file.uri) - .build() - } - - val flowSlot = slot>() - every { playbackModel.setFilesFlow(capture(flowSlot)) } just Runs - - val result = launcher.launch() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 1) { playbackModel.start() } - verify(exactly = 1) { playbackModel.setFilesFlow(any()) } - assert(result.mediaItems.size == 3) - assert(result.startIndex == 1) - assert(result.mediaItems[1].mediaId == currentFileId) - - val collectedSecond = mutableListOf() - val downstreamDispatcher = UnconfinedTestDispatcher(testScheduler) - withContext(downstreamDispatcher) { - flowSlot.captured.collect { collectedSecond += it } - } - - assert(collectedSecond.size == 1) - assert(collectedSecond.first().list.size == 2) - assert(collectedSecond.first().list.first().id == currentFileId) - } - - @Test - fun launch_fallback_when_config_null_returns_stub() = runTest { - every { configStore.loadConfig() } returns null - every { mediaItemFactory.create(any()) } answers { - val file = firstArg() - MediaItem.Builder().setMediaId(file.id).setUri(file.uri).build() - } - - val result = launcher.launch() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 1) { playbackModel.start() } - assert(result.mediaItems.size == 1) - assert(result.mediaItems.first().mediaId == "0") - assert(result.startIndex == 0) - verify(exactly = 0) { playbackModel.setFilesFlow(any()) } - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt deleted file mode 100644 index 32d34a9ceb3c..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListenerTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.resumption - -import android.content.Context -import androidx.media3.common.MediaItem -import androidx.test.core.app.ApplicationProvider -import com.nextcloud.client.player.model.file.PlaybackFileType -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test - -class PlaybackResumptionPlayerListenerTest { - - private lateinit var store: PlaybackResumptionConfigStore - private lateinit var listener: PlaybackResumptionPlayerListener - - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - store = PlaybackResumptionConfigStore(context) - store.saveConfig("oldId", 1L, PlaybackFileType.AUDIO, null) - listener = PlaybackResumptionPlayerListener(store) - } - - @Test - fun onMediaItemTransition_updates_id() { - val item = MediaItem.Builder().setMediaId("newId").build() - listener.onMediaItemTransition(item, 0) - val loaded = store.loadConfig() - requireNotNull(loaded) - assertEquals("newId", loaded.currentFileId) - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt deleted file mode 100644 index ec2062ad01da..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoaderTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.session - -import android.graphics.Bitmap -import android.graphics.Bitmap.Config.ARGB_8888 -import androidx.media3.common.MediaMetadata -import androidx.test.platform.app.InstrumentationRegistry -import com.nextcloud.client.player.media3.common.setExtras -import com.nextcloud.client.player.model.ThumbnailLoader -import com.nextcloud.client.player.model.file.PlaybackFile -import io.mockk.every -import io.mockk.mockk -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNotSame -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Future - -class MediaSessionBitmapLoaderTest { - - private lateinit var thumbnailLoader: ThumbnailLoader - private lateinit var bitmapLoader: MediaSessionBitmapLoader - private val context get() = InstrumentationRegistry.getInstrumentation().targetContext - - private val playbackFile = PlaybackFile( - id = "123", - uri = "/remote.php/dav/files/user/song.mp3", - name = "song.mp3", - mimeType = "audio/mpeg", - contentLength = 1L, - lastModified = 0L, - isFavorite = false - ) - - @Before - fun setup() { - thumbnailLoader = mockk(relaxed = true) - bitmapLoader = MediaSessionBitmapLoader(context, thumbnailLoader) - } - - @Test - fun loads_from_artworkData() { - val expected = bitmap(0xFF00FF00.toInt()) - every { - thumbnailLoader.load(context, any(), playbackFile.id, any(), any()) - } returns completed(expected) - - val future = bitmapLoader.loadBitmapFromMetadata(metadata(artworkData = byteArrayOf(1, 2, 3)))!! - assertSame(expected, future.get()) - } - - @Test - fun loads_from_file_when_no_artwork() { - val expected = bitmap(0xFFFF0000.toInt()) - every { - thumbnailLoader.load(context, playbackFile, any(), any()) - } returns completed(expected) - - val future = bitmapLoader.loadBitmapFromMetadata(metadata())!! - assertSame(expected, future.get()) - } - - @Test - fun falls_back_to_default_icon_when_null() { - every { - thumbnailLoader.load(context, playbackFile, any(), any()) - } returns completed(null) - - val future = bitmapLoader.loadBitmapFromMetadata(metadata())!! - val result = future.get() - assertNotNull(result) - assertTrue(result.width > 0 && result.height > 0) - } - - @Test - fun returns_cached_future_for_same_request() { - val bitmap = bitmap(0xFF112233.toInt()) - every { - thumbnailLoader.load(context, any(), playbackFile.id, any(), any()) - } returns completed(bitmap) - - val metadata = metadata(artworkData = byteArrayOf(9)) - val future1 = bitmapLoader.loadBitmapFromMetadata(metadata)!! - val future2 = bitmapLoader.loadBitmapFromMetadata(metadata)!! - assertSame(future1, future2) - } - - @Test - fun different_artworkData_invalidates_cache() { - val bitmap1 = bitmap(0xFF010101.toInt()) - val bitmap2 = bitmap(0xFF020202.toInt()) - every { - thumbnailLoader.load(context, any(), playbackFile.id, any(), any()) - } returnsMany listOf(completed(bitmap1), completed(bitmap2)) - - val future1 = bitmapLoader.loadBitmapFromMetadata(metadata(artworkData = byteArrayOf(1)))!! - val future2 = bitmapLoader.loadBitmapFromMetadata(metadata(artworkData = byteArrayOf(2)))!! - assertNotSame(future1, future2) - assertNotSame(future1.get(), future2.get()) - } - - private fun bitmap(color: Int): Bitmap = Bitmap.createBitmap(intArrayOf(color), 1, 1, ARGB_8888) - - private fun completed(bitmap: Bitmap?): Future = CompletableFuture.completedFuture(bitmap) - - private fun metadata(artworkData: ByteArray? = null): MediaMetadata = MediaMetadata.Builder() - .setExtras(playbackFile) - .setArtworkData(artworkData, MediaMetadata.PICTURE_TYPE_FRONT_COVER) - .build() -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt b/app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt deleted file mode 100644 index 02173dc16f16..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/media3/session/TestMediaSessionFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.session - -import android.content.Context -import androidx.media3.session.MediaSession -import com.nextcloud.client.player.media3.common.PlayerFactory -import java.util.UUID - -class TestMediaSessionFactory(private val context: Context, private val playerFactory: PlayerFactory) : - MediaSessionFactory { - - override fun create(): MediaSession { - val player = playerFactory.create() - return MediaSession.Builder(context, player) - .setId(UUID.randomUUID().toString()) - .build() - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt deleted file mode 100644 index 9a832ef475c8..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/model/PlaybackSettingsTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.model - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.test.platform.app.InstrumentationRegistry -import com.nextcloud.client.player.model.state.RepeatMode -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class PlaybackSettingsTest { - - private lateinit var context: Context - private lateinit var preferences: SharedPreferences - - @Before - fun setup() { - context = InstrumentationRegistry.getInstrumentation().targetContext - preferences = context.getSharedPreferences("playback_settings", Context.MODE_PRIVATE) - } - - @Test - fun returns_default_values_when_empty() { - val settings = PlaybackSettings(context) - assertEquals(RepeatMode.ALL, settings.repeatMode) - assertFalse(settings.isShuffle) - settings.reset() - } - - @Test - fun setRepeatMode_persists_value() { - val settings = PlaybackSettings(context) - settings.setRepeatMode(RepeatMode.SINGLE) - val reloaded = PlaybackSettings(context) - assertEquals(RepeatMode.SINGLE, reloaded.repeatMode) - settings.reset() - } - - @Test - fun setShuffle_persists_value() { - val settings = PlaybackSettings(context) - settings.setShuffle(true) - val reloaded = PlaybackSettings(context) - assertTrue(reloaded.isShuffle) - settings.reset() - } - - @Test - fun falls_back_to_default_when_invalid_RepeatMode_is_stored() { - preferences.edit { putInt("repeat_mode_id", 999) } - val settings = PlaybackSettings(context) - assertEquals(RepeatMode.ALL, settings.repeatMode) - settings.reset() - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt deleted file mode 100644 index cfbf9e169339..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileMapperTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.model.file - -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.resources.shares.OCShare -import org.junit.Assert.assertEquals -import org.junit.Test - -class PlaybackFileMapperTest { - - @Test - fun ocFile_maps_to_PlaybackFile() { - val ocFile = OCFile("/Documents/music/test.mp3").apply { - fileId = 123L - localId = 123L - fileLength = 42_000 - modificationTimestamp = 1_700_000_000_000L - mimeType = "audio/mpeg" - isFavorite = true - } - - val playback = ocFile.toPlaybackFile() - - assertEquals("123", playback.id) - assertEquals("remoteFile:///123", playback.uri) - assertEquals("test.mp3", playback.name) - assertEquals("audio/mpeg", playback.mimeType) - assertEquals(42_000, playback.contentLength) - assertEquals(1_700_000_000_000L, playback.lastModified) - assertEquals(true, playback.isFavorite) - } - - @Test - fun ocShare_maps_to_PlaybackFile_with_mimetype_fallback() { - val share = OCShare("/Shared/music/test.mp3").apply { - fileSource = 555L - mimetype = null - sharedDate = 1_700_111_222L - isFavorite = false - } - - val playback = share.toPlaybackFile() - - assertEquals("555", playback.id) - assertEquals("remoteFile:///555", playback.uri) - assertEquals("test.mp3", playback.name) - assertEquals("audio/mpeg", playback.mimeType) - assertEquals(1_700_111_222_000L, playback.lastModified) - assertEquals(false, playback.isFavorite) - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt deleted file mode 100644 index 6c5fa42d1f82..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapperTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.model.file - -import android.net.Uri -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.resources.shares.OCShare -import org.junit.Assert -import org.junit.Test - -class PlaybackFileUriMapperTest { - - @Test - fun getPlaybackUri_from_OCFile_uses_localId() { - val file = OCFile("/root/music/song.mp3").apply { - localId = 12345L - } - - val uri = file.getPlaybackUri() - Assert.assertEquals("remoteFile", uri.scheme) - Assert.assertEquals("12345", uri.lastPathSegment) - Assert.assertEquals(12345L, uri.getRemoteFileId()) - } - - @Test - fun getPlaybackUri_from_OCShare_uses_fileSource() { - val share = OCShare().apply { - fileSource = 9999L - } - - val uri = share.getPlaybackUri() - Assert.assertEquals("remoteFile", uri.scheme) - Assert.assertEquals("9999", uri.lastPathSegment) - Assert.assertEquals(9999L, uri.getRemoteFileId()) - } - - @Test - fun getRemoteFileId_returns_null_for_different_scheme() { - val other = Uri.parse("content://anything/123") - Assert.assertNull(other.getRemoteFileId()) - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt deleted file mode 100644 index 433a4a339062..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/model/file/PlaybackFilesRepositoryTest.kt +++ /dev/null @@ -1,312 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.model.file - -import android.content.ContentUris -import android.net.Uri -import com.nextcloud.client.preferences.AppPreferences -import com.owncloud.android.datamodel.FileDataStorageManager -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.db.ProviderMeta.ProviderTableMeta -import com.owncloud.android.lib.resources.shares.OCShare -import com.owncloud.android.ui.fragment.SearchType -import com.owncloud.android.utils.FileSortOrder -import com.owncloud.android.utils.MimeTypeUtil -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test - -@Suppress("TooManyFunctions") -class PlaybackFilesRepositoryTest { - private lateinit var storageManager: FileDataStorageManager - private lateinit var preferences: AppPreferences - private lateinit var repository: PlaybackFilesRepository - private lateinit var contentObserver: FakeContentObserver - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private val favorites = listOf( - ocFile("/files/user/d1.docx", favorite = true), - ocFile("/files/user/a2.mp3", favorite = true), - ocFile("/files/user/v2.mkv", favorite = true), - ocFile("/files/user/i1.jpg", favorite = true), - ocFile("/files/user/a1.flac", favorite = true), - ocFile("/files/user/v1.mp4", favorite = true) - ) - - private val galleryItems = listOf( - ocFile("/files/user/i1.jpg", lastModified = 100L), - ocFile("/files/user/v1.mp4", lastModified = 200L), - ocFile("/files/user/i2.png", lastModified = 300L), - ocFile("/files/user/v2.mkv", lastModified = 400L) - ) - - private val shares = listOf( - ocShare("/files/user/d1.docx", shareDate = 100L), - ocShare("/files/user/a1.mp3", shareDate = 200L), - ocShare("/files/user/v1.mkv", shareDate = 300L), - ocShare("/files/user/i1.jpg", shareDate = 400L), - ocShare("/files/user/a2.flac", shareDate = 500L), - ocShare("/files/user/v2.mp4", shareDate = 600L) - ) - - private val folderItems = listOf( - ocFile("/files/user/folder/d1.docx", favorite = false), - ocFile("/files/user/folder/a1.mp3", favorite = false), - ocFile("/files/user/folder/v1.mkv", favorite = false), - ocFile("/files/user/folder/i1.jpg", favorite = false), - ocFile("/files/user/folder/a2.flac", favorite = true), - ocFile("/files/user/folder/v2.mp4", favorite = true) - ) - - private val folder = OCFile("/files/user/folder").apply { - localId = 1234L - mimeType = "DIR" - } - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - - storageManager = mockk(relaxed = true) - preferences = mockk(relaxed = true) - contentObserver = FakeContentObserver() - repository = PlaybackFilesRepository( - storageManager, - preferences, - testDispatcher, - contentObserver - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun get_favorite_audio_playback_files() = testScope.runTest { - every { storageManager.favoriteFiles } returns favorites - val playbackFiles = repository.get(0L, PlaybackFileType.AUDIO, SearchType.FAVORITE_SEARCH) - assertEquals(listOf("a1.flac", "a2.mp3"), playbackFiles.list.map { it.name }) - } - - @Test - fun get_favorite_video_playback_files() = testScope.runTest { - every { storageManager.favoriteFiles } returns favorites - val playbackFiles = repository.get(0L, PlaybackFileType.VIDEO, SearchType.FAVORITE_SEARCH) - assertEquals(listOf("v1.mp4", "v2.mkv"), playbackFiles.list.map { it.name }) - } - - @Test - fun observe_favorite_audio_playback_files() { - val favorites = favorites.toMutableList() - every { storageManager.favoriteFiles } answers { favorites } - assertObserve( - flow = repository.observe(0L, PlaybackFileType.AUDIO, SearchType.FAVORITE_SEARCH), - contentUri = ProviderTableMeta.CONTENT_URI, - trigger1 = { favorites.add(ocFile("/files/user/a4.mp3")) }, - trigger2 = { favorites.add(ocFile("/files/user/a3.mp3")) }, - expected1 = listOf("a1.flac", "a2.mp3"), - expected2 = listOf("a1.flac", "a2.mp3", "a3.mp3", "a4.mp3") - ) - } - - @Test - fun observe_favorite_video_playback_files() { - val favorites = favorites.toMutableList() - every { storageManager.favoriteFiles } answers { favorites } - assertObserve( - flow = repository.observe(0L, PlaybackFileType.VIDEO, SearchType.FAVORITE_SEARCH), - contentUri = ProviderTableMeta.CONTENT_URI, - trigger1 = { favorites.add(ocFile("/files/user/v4.mp4")) }, - trigger2 = { favorites.add(ocFile("/files/user/v3.mp4")) }, - expected1 = listOf("v1.mp4", "v2.mkv"), - expected2 = listOf("v1.mp4", "v2.mkv", "v3.mp4", "v4.mp4") - ) - } - - @Test - fun get_gallery_video_playback_files() = testScope.runTest { - every { storageManager.allGalleryItems } returns galleryItems - val playbackFiles = repository.get(0L, PlaybackFileType.VIDEO, SearchType.GALLERY_SEARCH) - assertEquals(listOf("v2.mkv", "v1.mp4"), playbackFiles.list.map { it.name }) - } - - @Test - fun observe_gallery_video_playback_files() { - val galleryItems = galleryItems.toMutableList() - every { storageManager.allGalleryItems } answers { galleryItems } - assertObserve( - flow = repository.observe(0L, PlaybackFileType.VIDEO, SearchType.GALLERY_SEARCH), - contentUri = ProviderTableMeta.CONTENT_URI, - trigger1 = { galleryItems.add(ocFile("/files/user/v3.mp4", lastModified = 500L)) }, - trigger2 = { galleryItems.add(ocFile("/files/user/v4.mp4", lastModified = 600L)) }, - expected1 = listOf("v2.mkv", "v1.mp4"), - expected2 = listOf("v4.mp4", "v3.mp4", "v2.mkv", "v1.mp4") - ) - } - - @Test - fun get_shared_audio_playback_files() = testScope.runTest { - every { storageManager.shares } returns shares - val playbackFiles = repository.get(0L, PlaybackFileType.AUDIO, SearchType.SHARED_FILTER) - assertEquals(listOf("a2.flac", "a1.mp3"), playbackFiles.list.map { it.name }) - } - - @Test - fun get_shared_video_playback_files() = testScope.runTest { - every { storageManager.shares } returns shares - val playbackFiles = repository.get(0L, PlaybackFileType.VIDEO, SearchType.SHARED_FILTER) - assertEquals(listOf("v2.mp4", "v1.mkv"), playbackFiles.list.map { it.name }) - } - - @Test - fun observe_shared_audio_playback_files() { - val shares = shares.toMutableList() - every { storageManager.shares } answers { shares } - assertObserve( - flow = repository.observe(0L, PlaybackFileType.AUDIO, SearchType.SHARED_FILTER), - contentUri = ProviderTableMeta.CONTENT_URI_SHARE, - trigger1 = { shares.add(ocShare("/files/user/a3.mp3", 700L)) }, - trigger2 = { shares.add(ocShare("/files/user/a4.mp3", 800L)) }, - expected1 = listOf("a2.flac", "a1.mp3"), - expected2 = listOf("a4.mp3", "a3.mp3", "a2.flac", "a1.mp3") - ) - } - - @Test - fun observe_shared_video_playback_files() { - val shares = shares.toMutableList() - every { storageManager.shares } answers { shares } - assertObserve( - flow = repository.observe(0L, PlaybackFileType.VIDEO, SearchType.SHARED_FILTER), - contentUri = ProviderTableMeta.CONTENT_URI_SHARE, - trigger1 = { shares.add(ocShare("/files/user/v3.mp4", 700L)) }, - trigger2 = { shares.add(ocShare("/files/user/v4.mp4", 800L)) }, - expected1 = listOf("v2.mp4", "v1.mkv"), - expected2 = listOf("v4.mp4", "v3.mp4", "v2.mp4", "v1.mkv") - ) - } - - @Test - fun get_folder_audio_playback_files() = testScope.runTest { - mockFolder(folder, folderItems) - val playbackFiles = repository.get(folder.localId, PlaybackFileType.AUDIO, null) - assertEquals(listOf("a2.flac", "a1.mp3"), playbackFiles.list.map { it.name }) - } - - @Test - fun get_folder_video_playback_files() = testScope.runTest { - mockFolder(folder, folderItems) - val playbackFiles = repository.get(folder.localId, PlaybackFileType.VIDEO, null) - assertEquals(listOf("v2.mp4", "v1.mkv"), playbackFiles.list.map { it.name }) - } - - @Test - fun observe_folder_audio_playback_files() { - val folderItems = folderItems.toMutableList() - mockFolder(folder, folderItems) - assertObserve( - flow = repository.observe(folder.localId, PlaybackFileType.AUDIO, null), - contentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, folder.localId), - trigger1 = { folderItems.add(ocFile("/files/user/folder/a3.mp3", favorite = true)) }, - trigger2 = { folderItems.add(ocFile("/files/user/folder/a4.mp3", favorite = false)) }, - expected1 = listOf("a2.flac", "a1.mp3"), - expected2 = listOf("a2.flac", "a3.mp3", "a1.mp3", "a4.mp3") - ) - } - - @Test - fun observe_folder_video_playback_files() { - val folderItems = folderItems.toMutableList() - mockFolder(folder, folderItems) - assertObserve( - flow = repository.observe(folder.localId, PlaybackFileType.VIDEO, null), - contentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, folder.localId), - trigger1 = { folderItems.add(ocFile("/files/user/folder/v3.mp4", favorite = false)) }, - trigger2 = { folderItems.add(ocFile("/files/user/folder/v4.mp4", favorite = true)) }, - expected1 = listOf("v2.mp4", "v1.mkv"), - expected2 = listOf("v2.mp4", "v4.mp4", "v1.mkv", "v3.mp4") - ) - } - - private fun assertObserve( - flow: Flow, - contentUri: Uri, - trigger1: () -> Unit, - trigger2: () -> Unit, - expected1: List, - expected2: List - ) = testScope.runTest { - val emissions = mutableListOf() - val job = launch { flow.toList(emissions) } - - advanceUntilIdle() - assertEquals(expected1, emissions[0].list.map { it.name }) - - trigger1() - contentObserver.emit(contentUri) - delay(100L) - trigger2() - contentObserver.emit(contentUri) - - advanceUntilIdle() - assertEquals(2, emissions.size) - assertEquals(expected2, emissions[1].list.map { it.name }) - - job.cancel() - } - - private fun mockFolder(folder: OCFile, items: List) { - every { storageManager.getFileById(folder.localId) } returns folder - every { storageManager.getFolderContent(folder, any()) } returns items - every { preferences.getSortOrderByFolder(folder) } returns FileSortOrder.SORT_A_TO_Z - } - - private fun ocFile(path: String, lastModified: Long = 0L, favorite: Boolean = false): OCFile = OCFile(path).apply { - localId = path.hashCode().toLong() - mimeType = MimeTypeUtil.getMimeTypeFromPath(path) - modificationTimestamp = lastModified - isFavorite = favorite - } - - private fun ocShare(path: String, shareDate: Long): OCShare = OCShare(path).apply { - fileSource = path.hashCode().toLong() - mimetype = MimeTypeUtil.getMimeTypeFromPath(path) - sharedDate = shareDate - } - - class FakeContentObserver : (Uri, Boolean) -> Flow { - private val map = mutableMapOf>() - - override fun invoke(uri: Uri, notify: Boolean): Flow = map.getOrPut(uri) { - MutableSharedFlow(extraBufferCapacity = 16) - } - - fun emit(uri: Uri) { - map[uri]?.tryEmit(true) - } - } -} diff --git a/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt b/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt deleted file mode 100644 index e14807792653..000000000000 --- a/app/src/androidTest/java/com/nextcloud/client/player/ui/control/PlayerControlViewTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.ui.control - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import com.nextcloud.client.player.model.PlaybackModel -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.state.PlaybackItemState -import com.nextcloud.client.player.model.state.PlaybackState -import com.nextcloud.client.player.model.state.PlayerState -import com.nextcloud.client.player.model.state.RepeatMode -import io.mockk.CapturingSlot -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.junit.Before -import org.junit.Test -import java.util.Optional - -@Suppress("TooManyFunctions") -class PlayerControlViewTest { - - private lateinit var playbackModel: PlaybackModel - private lateinit var view: PlayerControlView - private lateinit var listenerSlot: CapturingSlot - - @Before - fun setup() { - playbackModel = mockk(relaxed = true) - listenerSlot = slot() - - every { playbackModel.addListener(capture(listenerSlot)) } returns Unit - every { playbackModel.removeListener(any()) } returns Unit - every { playbackModel.state } returns Optional.empty() - - val context = ApplicationProvider.getApplicationContext() - view = PlayerControlView(context, injectedPlaybackModel = playbackModel) - view.onStart() - } - - @Test - fun playPause_whenPlaying_invokesPause() { - val files = listOf(mockFile()) - val itemState = itemState(PlayerState.PLAYING, 1_000, 5_000) - pushState(playbackState(files = files, itemState = itemState)) - view.binding.ivPlayPause.performClick() - verify { playbackModel.pause() } - } - - @Test - fun playPause_whenPaused_invokesPlay() { - val files = listOf(mockFile()) - val itemState = itemState(PlayerState.PAUSED, 2_000, 6_000) - pushState(playbackState(files = files, itemState = itemState)) - view.binding.ivPlayPause.performClick() - verify { playbackModel.play() } - } - - @Test - fun repeat_clickFromAll_setsSingle() { - pushState(playbackState(repeat = RepeatMode.ALL)) - view.binding.ivRepeat.performClick() - verify { playbackModel.setRepeatMode(RepeatMode.SINGLE) } - } - - @Test - fun repeat_clickFromSingle_setsAll() { - pushState(playbackState(repeat = RepeatMode.SINGLE)) - view.binding.ivRepeat.performClick() - verify { playbackModel.setRepeatMode(RepeatMode.ALL) } - } - - @Test - fun shuffle_clickFromOff_enablesShuffle() { - pushState(playbackState(shuffle = false)) - view.binding.ivRandom.performClick() - verify { playbackModel.setShuffle(true) } - } - - @Test - fun shuffle_clickFromOn_disablesShuffle() { - pushState(playbackState(shuffle = true)) - view.binding.ivRandom.performClick() - verify { playbackModel.setShuffle(false) } - } - - @Test - fun nextPrevious_enablement_singleItem() { - val files = listOf(mockFile()) - val itemState = itemState(PlayerState.PLAYING, 500, 2_000) - pushState(playbackState(files = files, itemState = itemState)) - assert(!view.binding.ivNext.isEnabled) - assert(view.binding.ivPrevious.isEnabled) - } - - @Test - fun nextPrevious_enablement_multipleItems() { - val files = listOf(mockFile(), mockFile()) - val itemState = itemState(PlayerState.PAUSED, 500, 2_000) - pushState(playbackState(files = files, itemState = itemState)) - assert(view.binding.ivNext.isEnabled) - assert(view.binding.ivPrevious.isEnabled) - } - - @Test - fun nextPrevious_disabledWhenNoCurrentItem() { - val files = listOf(mockFile(), mockFile()) - pushState(playbackState(files = files, itemState = null)) - assert(!view.binding.ivNext.isEnabled) - assert(!view.binding.ivPrevious.isEnabled) - } - - @Test - fun progressBar_indeterminateWhenNoItem() { - pushState(playbackState(itemState = null)) - assert(view.binding.progressBar.progress == 0) - assert(view.binding.tvElapsed.text.toString() == "--:--") - assert(view.binding.tvTotalTime.text.toString() == "--:--") - } - - @Test - fun progressBar_rendersValuesUnderHour() { - val itemState = itemState(PlayerState.PLAYING, current = 65_000, max = 125_000) - pushState(playbackState(itemState = itemState)) - assert(view.binding.progressBar.max == 125_000) - assert(view.binding.progressBar.progress == 65_000) - assert(view.binding.tvTotalTime.text.toString() == "02:05") - assert(view.binding.tvElapsed.text.toString() == "01:05") - } - - @Test - fun progressBar_rendersValuesOverHour() { - val itemState = itemState(PlayerState.PLAYING, current = 605_000, max = 3_726_000) - pushState(playbackState(itemState = itemState)) - assert(view.binding.progressBar.max == 3_726_000) - assert(view.binding.progressBar.progress == 605_000) - assert(view.binding.tvTotalTime.text.toString() == "01:02:06") - assert(view.binding.tvElapsed.text.toString() == "00:10:05") - } - - private fun mockFile(): PlaybackFile = mockk(relaxed = true) - - private fun itemState(state: PlayerState, current: Long, max: Long) = PlaybackItemState( - file = mockFile(), - playerState = state, - metadata = null, - videoSize = null, - currentTimeInMilliseconds = current, - maxTimeInMilliseconds = max - ) - - private fun playbackState( - files: List = emptyList(), - itemState: PlaybackItemState? = null, - repeat: RepeatMode = RepeatMode.ALL, - shuffle: Boolean = false - ) = PlaybackState( - currentFiles = files, - currentItemState = itemState, - repeatMode = repeat, - shuffle = shuffle - ) - - private fun pushState(state: PlaybackState) { - every { playbackModel.state } returns Optional.of(state) - listenerSlot.captured.onPlaybackUpdate(state) - } -} From c73ec19aaea963793f1c8d902076cc423115ea63 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 15:03:07 +0200 Subject: [PATCH 23/69] wip Signed-off-by: alperozturk96 --- app/build.gradle.kts | 2 - .../MediaControllerExtensionsTest.kt | 107 ------------- .../error/DefaultPlaybackErrorStrategyTest.kt | 67 -------- .../model/file/PlaybackFilesComparatorTest.kt | 145 ------------------ .../client/player/util/ListExtensionsTest.kt | 28 ---- 5 files changed, 349 deletions(-) delete mode 100644 app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt delete mode 100644 app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt delete mode 100644 app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt delete mode 100644 app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 061ead8b3435..daa3fd97efc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -528,8 +528,6 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.guava) - testImplementation(libs.kotlinx.coroutines.test) - androidTestImplementation(libs.kotlinx.coroutines.test) // endregion // region Stateless diff --git a/app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt b/app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt deleted file mode 100644 index 7b9fa1d03ff9..000000000000 --- a/app/src/test/java/com/nextcloud/client/player/media3/controller/MediaControllerExtensionsTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.media3.controller - -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.session.MediaController -import com.nextcloud.client.player.model.state.RepeatMode -import io.mockk.clearMocks -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import io.mockk.verifyOrder -import org.junit.Assert.assertEquals -import org.junit.Test - -class MediaControllerExtensionsTest { - - private fun item(id: String) = MediaItem.Builder().setMediaId(id).build() - - @Test - fun `indexOfFirst found and not found`() { - val controller = mockk(relaxed = true) - val items = listOf(item("A"), item("B"), item("C")) - - every { controller.mediaItemCount } returns items.size - every { controller.getMediaItemAt(any()) } answers { items[firstArg()] } - - val found = controller.indexOfFirst { it.mediaId == "B" } - val notFound = controller.indexOfFirst { it.mediaId == "X" } - - assertEquals(1, found) - assertEquals(-1, notFound) - } - - @Test - fun `updateMediaItems updates around current`() { - val controller = mockk(relaxed = true) - - every { controller.currentMediaItemIndex } returns 1 - every { controller.currentMediaItem } returns item("B") - every { controller.mediaItemCount } returns 3 - - val new = listOf(item("A"), item("B"), item("D"), item("E")) - - controller.updateMediaItems(new) - - verifyOrder { - controller.removeMediaItems(2, 3) - controller.addMediaItems(listOf(new[2], new[3])) - controller.removeMediaItems(0, 1) - controller.addMediaItems(0, listOf(new[0])) - controller.replaceMediaItem(1, new[1]) - } - - verify(exactly = 0) { - controller.setMediaItems(any>()) - } - } - - @Test - fun `updateMediaItems falls back to setMediaItems when no match`() { - val controller = mockk(relaxed = true) - - every { controller.currentMediaItemIndex } returns 0 - every { controller.currentMediaItem } returns item("X") - every { controller.mediaItemCount } returns 2 - - val new = listOf(item("A"), item("B")) - - controller.updateMediaItems(new) - - verify { - controller.setMediaItems(new) - } - - verify(exactly = 0) { - controller.removeMediaItems(any(), any()) - controller.addMediaItems(any>()) - controller.addMediaItems(any(), any>()) - controller.replaceMediaItem(any(), any()) - } - } - - @Test - fun `setRepeatMode maps to Player constants`() { - val controller = mockk(relaxed = true) - - controller.setRepeatMode(RepeatMode.SINGLE) - verify { controller.repeatMode = Player.REPEAT_MODE_ONE } - - clearMocks(controller, answers = false, recordedCalls = true, verificationMarks = true) - - controller.setRepeatMode(RepeatMode.ALL) - verify { controller.repeatMode = Player.REPEAT_MODE_ALL } - - clearMocks(controller, answers = false, recordedCalls = true, verificationMarks = true) - - controller.setRepeatMode(RepeatMode.OFF) - verify { controller.repeatMode = Player.REPEAT_MODE_OFF } - } -} diff --git a/app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt b/app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt deleted file mode 100644 index f731dd827113..000000000000 --- a/app/src/test/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategyTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.model.error - -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.state.PlaybackItemState -import com.nextcloud.client.player.model.state.PlaybackState -import com.nextcloud.client.player.model.state.PlayerState -import com.nextcloud.client.player.model.state.RepeatMode -import org.junit.Assert -import org.junit.Test - -class DefaultPlaybackErrorStrategyTest { - private val strategy = DefaultPlaybackErrorStrategy() - - @Test - fun `switchToNextSource returns false when one file in queue`() { - val state = createState(mockWithName("a"), mockWithName("a")) - val switchToNext = strategy.switchToNextSource(RuntimeException(), state) - Assert.assertFalse(switchToNext) - } - - @Test - fun `switchToNextSource returns false when current file is last`() { - val state = createState(mockWithName("b"), mockWithName("a"), mockWithName("b")) - val switchToNext = strategy.switchToNextSource(RuntimeException(), state) - Assert.assertFalse(switchToNext) - } - - @Test - fun `switchToNextSource returns true when current file is not last`() { - val state = createState(mockWithName("b"), mockWithName("a"), mockWithName("a")) - val switchToNext = strategy.switchToNextSource(RuntimeException(), state) - Assert.assertTrue(switchToNext) - } - - private fun createState(currentFile: PlaybackFile, vararg files: PlaybackFile): PlaybackState = PlaybackState( - currentFiles = files.toList(), - currentItemState = currentFile.toPlaybackItemState(), - repeatMode = RepeatMode.OFF, - shuffle = false - ) - - private fun mockWithName(name: String): PlaybackFile = PlaybackFile( - id = name, - uri = name, - name = "fakeUri:///$name", - mimeType = "audio/mp3", - contentLength = 0, - lastModified = 0, - isFavorite = false - ) - - private fun PlaybackFile.toPlaybackItemState() = PlaybackItemState( - file = this, - playerState = PlayerState.NONE, - metadata = null, - videoSize = null, - currentTimeInMilliseconds = 0L, - maxTimeInMilliseconds = 0L - ) -} diff --git a/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt b/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt deleted file mode 100644 index 3a1c786b99f8..000000000000 --- a/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFilesComparatorTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.model.file - -import com.owncloud.android.utils.FileSortOrder -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class PlaybackFilesComparatorTest { - - @Test - fun `NONE comparator always returns 0`() { - val a = file("a") - val b = file("b") - assertEquals(0, PlaybackFilesComparator.NONE.compare(a, b)) - assertEquals(0, PlaybackFilesComparator.NONE.compare(b, a)) - } - - @Test - fun `FAVORITE uses natural alphanumeric ordering`() { - val list = listOf(file("file10"), file("file2"), file("file1")) - val sorted = list.sortedWith(PlaybackFilesComparator.FAVORITE) - assertEquals(listOf("file1", "file2", "file10"), sorted.map { it.name }) - } - - @Test - fun `GALLERY sorts by lastModified descending`() { - val a = file("a", modified = 100) - val b = file("b", modified = 300) - val c = file("c", modified = 200) - val sorted = listOf(a, b, c).sortedWith(PlaybackFilesComparator.GALLERY) - assertEquals(listOf("b", "c", "a"), sorted.map { it.name }) - } - - @Test - fun `SHARED sorts by lastModified descending (same as GALLERY)`() { - val a = file("a", modified = 1) - val b = file("b", modified = 5) - val sorted = listOf(a, b).sortedWith(PlaybackFilesComparator.SHARED) - assertEquals(listOf("b", "a"), sorted.map { it.name }) - } - - @Test - fun `Folder comparator ALPHABET ascending with favorites first`() { - val list = listOf( - file("b2", favorite = true), - file("a10"), - file("a2", favorite = true), - file("a1"), - file("b10", favorite = true) - ) - val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.ALPHABET, isAscending = true) - val sorted = list.sortedWith(cmp) - assertEquals(listOf("a2", "b2", "b10", "a1", "a10"), sorted.map { it.name }) - assertTrue(sorted.take(3).all { it.isFavorite }) - assertTrue(sorted.drop(3).none { it.isFavorite }) - } - - @Test - fun `Folder comparator ALPHABET descending with favorites first`() { - val list = listOf( - file("x1"), - file("x10", favorite = true), - file("x2", favorite = true), - file("x11") - ) - val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.ALPHABET, isAscending = false) - val sorted = list.sortedWith(cmp) - assertEquals(listOf("x10", "x2", "x11", "x1"), sorted.map { it.name }) - } - - @Test - fun `Folder comparator SIZE ascending then favorites`() { - val list = listOf( - file("bigFav", favorite = true, size = 300), - file("smallFav", favorite = true, size = 100), - file("mid", size = 200), - file("tiny", size = 50) - ) - val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.SIZE, isAscending = true) - val sorted = list.sortedWith(cmp) - assertEquals(listOf("smallFav", "bigFav", "tiny", "mid"), sorted.map { it.name }) - } - - @Test - fun `Folder comparator SIZE descending`() { - val list = listOf( - file("a", size = 100), - file("b", size = 500), - file("c", size = 300) - ) - val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.SIZE, isAscending = false) - val sorted = list.sortedWith(cmp) - assertEquals(listOf("b", "c", "a"), sorted.map { it.name }) - } - - @Test - fun `Folder comparator DATE ascending`() { - val list = listOf( - file("newFav", favorite = true, modified = 300), - file("oldFav", favorite = true, modified = 100), - file("old", modified = 50), - file("mid", modified = 200) - ) - val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.DATE, isAscending = true) - val sorted = list.sortedWith(cmp) - assertEquals(listOf("oldFav", "newFav", "old", "mid"), sorted.map { it.name }) - } - - @Test - fun `Folder comparator DATE descending`() { - val list = listOf( - file("a", modified = 100), - file("b", modified = 400), - file("c", modified = 200) - ) - val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.DATE, isAscending = false) - val sorted = list.sortedWith(cmp) - assertEquals(listOf("b", "c", "a"), sorted.map { it.name }) - } - - @Test - fun `Alphanumeric edge cases with leading zeros`() { - val list = listOf(file("track02"), file("track2"), file("track10"), file("track01")) - val cmp = PlaybackFilesComparator.Folder(FileSortOrder.SortType.ALPHABET, isAscending = true) - val sorted = list.sortedWith(cmp) - assertEquals(listOf("track01", "track2", "track02", "track10"), sorted.map { it.name }) - } - - private fun file(name: String, favorite: Boolean = false, size: Long = 0, modified: Long = 0) = PlaybackFile( - id = "id_$name", - uri = "uri_$name", - name = name, - mimeType = "audio/mpeg", - contentLength = size, - lastModified = modified, - isFavorite = favorite - ) -} diff --git a/app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt b/app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt deleted file mode 100644 index e63642eb071c..000000000000 --- a/app/src/test/java/com/nextcloud/client/player/util/ListExtensionsTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: GPL-2.0 - */ - -package com.nextcloud.client.player.util - -import org.junit.Assert -import org.junit.Test - -class ListExtensionsTest { - private val inputList = listOf(1, 2, 3) - - @Test - fun `calculateShift returns expected shift`() { - val shift = inputList.calculateShift(0, inputList[2]) - Assert.assertEquals(1, shift) - } - - @Test - fun `rotate returns expected list`() { - val expectedList = listOf(3, 1, 2) - val rotatedList = inputList.rotate(1) - Assert.assertEquals(expectedList, rotatedList) - } -} From c57ad1a52f9240bb2c1b34d4866ec9bee28660ae Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 16:07:36 +0200 Subject: [PATCH 24/69] fix using new players Signed-off-by: alperozturk96 --- .../media3/session/MediaSessionCallback.kt | 17 ++++++++++---- .../ui/activity/FileDisplayActivity.kt | 3 --- .../ui/fragment/OCFileListFragment.java | 23 ++----------------- 3 files changed, 14 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt index e5764fee28c4..7291e47f0fae 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -35,12 +35,19 @@ class MediaSessionCallback @Inject constructor( const val CLOSE_ACTION = "CLOSE_ACTION" } + /** + * The result of [super.onConnect] carries no available player commands since media3 1.11.0, so the connection has + * to be built from [ConnectionResult.AcceptedResultBuilder] instead. Forwarding the commands of the super result + * leaves the controller unable to change media items, prepare or play. + */ override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): ConnectionResult { - val connectionResult = super.onConnect(session, controller) - val sessionCommandsBuilder = connectionResult.availableSessionCommands.buildUpon() - sessionCommandsBuilder.add(SessionCommand(CLOSE_ACTION, Bundle.EMPTY)) - val sessionCommands = sessionCommandsBuilder.build() - return ConnectionResult.accept(sessionCommands, connectionResult.availablePlayerCommands) + val sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(SessionCommand(CLOSE_ACTION, Bundle.EMPTY)) + .build() + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .build() } override fun onCustomCommand( diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 157c45f67291..797ba3c088ed 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -2085,9 +2085,6 @@ class FileDisplayActivity : } else if (PreviewTextFileFragment.canBePreviewed(file)) { setFabVisible?.onComplete(false) startTextPreview(file, false) - } else if (MimeTypeUtil.isVideo(file)) { - setFabVisible?.onComplete(false) - startImagePreview(file, true) } else if (canMediaPreviewed(file)) { setFabVisible?.onComplete(false) startMediaPreview(file, 0, true, true, false, true) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 25d8afed1f54..a8b2bc317f3f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -113,7 +113,6 @@ import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; -import com.owncloud.android.utils.MimeTypeUtil; import com.owncloud.android.utils.PermissionUtil; import com.owncloud.android.utils.WebViewUtil; import com.owncloud.android.utils.theme.ThemeUtils; @@ -145,7 +144,6 @@ import static com.owncloud.android.datamodel.OCFile.ROOT_PATH; import static com.owncloud.android.ui.fragment.SearchType.FAVORITE_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.FILE_SEARCH; -import static com.owncloud.android.ui.fragment.SearchType.GALLERY_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.NO_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.RECENT_FILES_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.SHARED_FILTER; @@ -1170,7 +1168,7 @@ private void fileOnItemClick(OCFile file) { return; } - if (canPreviewInVirtualFolderPager(file) && mContainerActivity instanceof FileDisplayActivity fda) { + if (PreviewImageFragment.canBePreviewed(file) && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewImageWithSearchContext(file, searchFragment, currentSearchType); } else if (file.isDown() && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewFile(file, this::setFabVisible); @@ -1179,20 +1177,6 @@ private void fileOnItemClick(OCFile file) { } } - /** - * In a gallery or favorites search the preview pager is built from the whole virtual folder, so a directly - * tapped video must open through the same pager as the images. - */ - private boolean canPreviewInVirtualFolderPager(OCFile file) { - if (PreviewImageFragment.canBePreviewed(file)) { - return true; - } - - boolean virtualFolderSearch = searchFragment - && (currentSearchType == GALLERY_SEARCH || currentSearchType == FAVORITE_SEARCH); - return virtualFolderSearch && MimeTypeUtil.isVideo(file); - } - private boolean supportsDirectEditing(OCFile file, boolean webViewAvailable) { if (!webViewAvailable) { return false; @@ -1221,10 +1205,7 @@ private void handlePendingDownloadFile(OCFile file) { boolean webViewAvailable = WebViewUtil.available(getContext()); - if (MimeTypeUtil.isVideo(file) && !file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda) { - setFabVisible(false); - fda.startImagePreview(file, true, null); - } else if (!file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda && fda.canMediaPreviewed(file)) { + if (!file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda && fda.canMediaPreviewed(file)) { setFabVisible(false); fda.startMediaPreview(file, 0, true, true, true, true); } else if (webViewAvailable && editorUtils.getEditor(accountManager.getUser(), file.getMimeType()) != null && !file.isEncrypted()) { From 69b48d72f37b8eb9791cfdfa04ca0d3966618426 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 16:15:58 +0200 Subject: [PATCH 25/69] simplify approach Signed-off-by: alperozturk96 # Conflicts: # app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt --- .../nextcloud/client/player/PlayerModule.kt | 54 +---- .../client/player/media3/ExoPlayerFactory.kt | 34 --- ...edia3PlaybackModel.kt => PlaybackModel.kt} | 146 +++++++------ .../client/player/media3/PlaybackService.kt | 38 +++- .../player/media3/PlaybackStateFactory.kt | 84 -------- .../player/media3/PlaybackStateMapper.kt | 73 +++++++ .../client/player/media3/common/MediaItem.kt | 20 ++ .../player/media3/common/MediaItemFactory.kt | 29 --- .../MediaController.kt => common/Player.kt} | 2 +- .../player/media3/common/PlayerFactory.kt | 14 -- .../DefaultMediaControllerFactory.kt | 29 --- .../controller/MediaControllerFactory.kt | 14 -- ...ultDataSource.kt => PlaybackDataSource.kt} | 18 +- ...actory.kt => PlaybackDataSourceFactory.kt} | 4 +- .../PlaybackResumptionConfigStore.kt | 6 - .../resumption/PlaybackResumptionLauncher.kt | 7 +- .../PlaybackResumptionPlayerListener.kt | 21 -- .../session/DefaultMediaSessionFactory.kt | 49 ----- .../session/MediaSessionActivityFactory.kt | 37 ---- .../media3/session/MediaSessionCallback.kt | 2 +- .../media3/session/MediaSessionFactory.kt | 59 ++++- .../media3/session/MediaSessionHolder.kt | 17 -- .../player/model/GlideThumbnailLoader.kt | 74 ------- .../client/player/model/PlaybackModel.kt | 64 ------ .../model/PlaybackModelCompositeListener.kt | 36 ---- .../client/player/model/PlaybackSettings.kt | 6 - .../client/player/model/ThumbnailLoader.kt | 58 ++++- .../error/DefaultPlaybackErrorStrategy.kt | 22 -- .../model/error/PlaybackErrorStrategy.kt | 15 -- .../player/model/file/PlaybackFileType.kt | 8 +- .../model/file/PlaybackFilesComparator.kt | 19 +- .../model/file/PlaybackFilesRepository.kt | 150 +++++-------- .../client/player/ui/PlayerActivity.kt | 3 +- .../client/player/ui/PlayerLauncher.kt | 8 +- .../player/ui/PlayerProgressIndicator.kt | 5 +- .../nextcloud/client/player/ui/PlayerView.kt | 23 +- .../client/player/ui/PlayerViewModel.kt | 13 +- .../player/ui/audio/AudioFileFragment.kt | 4 +- .../ui/audio/AudioFileFragmentFactory.kt | 17 -- .../client/player/ui/audio/AudioPlayerView.kt | 9 +- .../ui/control/MultipleClickListener.kt | 48 ++--- .../player/ui/control/PlayerControlView.kt | 43 ++-- .../client/player/ui/pager/PlayerPager.kt | 201 +++++------------- .../player/ui/pager/PlayerPagerAdapter.kt | 62 ++++++ .../ui/pager/PlayerPagerFragmentFactory.kt | 14 -- .../player/ui/pager/PlayerPagerListener.kt | 12 -- .../client/player/ui/pager/PlayerPagerMode.kt | 13 -- .../adapter/AbstractFragmentPagerAdapter.kt | 81 ------- .../adapter/DefaultFragmentPagerAdapter.kt | 31 --- .../adapter/InfiniteFragmentPagerAdapter.kt | 68 ------ .../player/ui/video/VideoFileFragment.kt | 5 +- .../ui/video/VideoFileFragmentFactory.kt | 17 -- .../client/player/ui/video/VideoPlayerView.kt | 9 +- .../nextcloud/client/player/util/ImageView.kt | 19 -- .../com/nextcloud/client/player/util/List.kt | 9 - .../client/player/util/ScreenUtils.kt | 18 +- .../android/ui/activity/DrawerActivity.java | 2 +- .../android/ui/dialog/AccountRemovalDialog.kt | 2 +- 58 files changed, 581 insertions(+), 1364 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt rename app/src/main/java/com/nextcloud/client/player/media3/{Media3PlaybackModel.kt => PlaybackModel.kt} (58%) delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt rename app/src/main/java/com/nextcloud/client/player/media3/{controller/MediaController.kt => common/Player.kt} (97%) delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt rename app/src/main/java/com/nextcloud/client/player/media3/datasource/{DefaultDataSource.kt => PlaybackDataSource.kt} (76%) rename app/src/main/java/com/nextcloud/client/player/media3/datasource/{DefaultDataSourceFactory.kt => PlaybackDataSourceFactory.kt} (95%) delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/util/ImageView.kt diff --git a/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt index 2f548abaa7dd..53cc8ec28e43 100644 --- a/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt +++ b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt @@ -9,25 +9,10 @@ package com.nextcloud.client.player import android.content.Context import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache -import com.nextcloud.client.player.media3.ExoPlayerFactory -import com.nextcloud.client.player.media3.Media3PlaybackModel import com.nextcloud.client.player.media3.PlaybackService -import com.nextcloud.client.player.media3.common.PlayerFactory -import com.nextcloud.client.player.media3.controller.DefaultMediaControllerFactory -import com.nextcloud.client.player.media3.controller.MediaControllerFactory -import com.nextcloud.client.player.media3.datasource.DefaultDataSourceFactory -import com.nextcloud.client.player.media3.session.DefaultMediaSessionFactory -import com.nextcloud.client.player.media3.session.MediaSessionFactory -import com.nextcloud.client.player.media3.session.MediaSessionHolder -import com.nextcloud.client.player.model.GlideThumbnailLoader -import com.nextcloud.client.player.model.PlaybackModel -import com.nextcloud.client.player.model.ThumbnailLoader -import com.nextcloud.client.player.model.error.DefaultPlaybackErrorStrategy -import com.nextcloud.client.player.model.error.PlaybackErrorStrategy import com.nextcloud.client.player.ui.PlayerActivity import com.nextcloud.client.player.ui.PlayerProgressIndicator import com.nextcloud.client.player.ui.audio.AudioFileFragment @@ -35,7 +20,6 @@ import com.nextcloud.client.player.ui.audio.AudioPlayerView import com.nextcloud.client.player.ui.control.PlayerControlView import com.nextcloud.client.player.ui.video.VideoFileFragment import com.nextcloud.client.player.ui.video.VideoPlayerView -import dagger.Binds import dagger.Module import dagger.Provides import dagger.android.ContributesAndroidInjector @@ -45,7 +29,7 @@ import javax.inject.Singleton private const val PLAYER_CACHE_DIR_NAME = "player" private const val PLAYER_CACHE_SIZE = 300 * 1024 * 1024L -@Module(includes = [PlayerModule.Bindings::class, PlayerModule.AndroidInjector::class]) +@Module(includes = [PlayerModule.AndroidInjector::class]) class PlayerModule { @Provides @@ -56,42 +40,6 @@ class PlayerModule { LeastRecentlyUsedCacheEvictor(PLAYER_CACHE_SIZE) ) - @Module - abstract class Bindings { - - @Binds - @Singleton - @UnstableApi - abstract fun playbackModel(model: Media3PlaybackModel): PlaybackModel - - @Binds - @Singleton - @UnstableApi - abstract fun mediaSessionHolder(playbackModel: Media3PlaybackModel): MediaSessionHolder - - @Binds - @UnstableApi - abstract fun mediaSessionFactory(sessionFactory: DefaultMediaSessionFactory): MediaSessionFactory - - @Binds - @UnstableApi - abstract fun mediaControllerFactory(controllerFactory: DefaultMediaControllerFactory): MediaControllerFactory - - @Binds - @UnstableApi - abstract fun playerFactory(playbackFactory: ExoPlayerFactory): PlayerFactory - - @Binds - @UnstableApi - abstract fun dataSourceFactory(dataSourceFactory: DefaultDataSourceFactory): DataSource.Factory - - @Binds - abstract fun playbackErrorStrategy(strategy: DefaultPlaybackErrorStrategy): PlaybackErrorStrategy - - @Binds - abstract fun thumbnailLoader(thumbnailLoader: GlideThumbnailLoader): ThumbnailLoader - } - @Module abstract class AndroidInjector { diff --git a/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt deleted file mode 100644 index f7af1e8c1645..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/ExoPlayerFactory.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3 - -import android.content.Context -import androidx.media3.common.AudioAttributes -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import com.nextcloud.client.player.media3.common.PlayerFactory -import javax.inject.Inject - -private const val FIVE_SECONDS_IN_MILLIS = 5000L - -@UnstableApi -class ExoPlayerFactory @Inject constructor( - private val context: Context, - private val dataSourceFactory: DataSource.Factory -) : PlayerFactory { - - override fun create(): Player = ExoPlayer.Builder(context) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setSeekForwardIncrementMs(FIVE_SECONDS_IN_MILLIS) - .build() -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt similarity index 58% rename from app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt rename to app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index a79587f27993..cf7e3c5aa92d 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/Media3PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -7,23 +7,22 @@ package com.nextcloud.client.player.media3 +import android.content.ComponentName +import android.content.Context import android.view.SurfaceView +import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.MediaSession -import com.nextcloud.client.player.media3.common.MediaItemFactory +import androidx.media3.session.SessionToken import com.nextcloud.client.player.media3.common.playbackFile -import com.nextcloud.client.player.media3.controller.MediaControllerFactory -import com.nextcloud.client.player.media3.controller.indexOfFirst -import com.nextcloud.client.player.media3.controller.setRepeatMode -import com.nextcloud.client.player.media3.controller.updateMediaItems +import com.nextcloud.client.player.media3.common.toMediaItem +import com.nextcloud.client.player.media3.common.indexOfFirst +import com.nextcloud.client.player.media3.common.setRepeatMode +import com.nextcloud.client.player.media3.common.updateMediaItems import com.nextcloud.client.player.media3.session.MediaSessionFactory -import com.nextcloud.client.player.media3.session.MediaSessionHolder -import com.nextcloud.client.player.model.PlaybackModel -import com.nextcloud.client.player.model.PlaybackModelCompositeListener import com.nextcloud.client.player.model.PlaybackSettings -import com.nextcloud.client.player.model.error.PlaybackErrorStrategy import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.state.PlaybackState @@ -37,37 +36,42 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch -import java.util.Optional import javax.inject.Inject import javax.inject.Singleton @Singleton -@UnstableApi -@Suppress("LongParameterList") -class Media3PlaybackModel @Inject constructor( - private val stateFactory: PlaybackStateFactory, +@OptIn(markerClass = [UnstableApi::class]) +@Suppress("TooManyFunctions") +class PlaybackModel @Inject constructor( + private val context: Context, private val mediaSessionFactory: MediaSessionFactory, - private val controllerFactory: MediaControllerFactory, - private val playbackSettings: PlaybackSettings, - private val mediaItemFactory: MediaItemFactory, - private val playbackErrorStrategy: PlaybackErrorStrategy -) : PlaybackModel, - MediaSessionHolder { + private val playbackSettings: PlaybackSettings +) { companion object { private const val CHECK_PROGRESS_INTERVAL = 1000L } - private val modelCompositeListener = PlaybackModelCompositeListener() + interface Listener { + + fun onPlaybackUpdate(state: PlaybackState) + + fun onPlaybackError(error: Throwable) { + // Default empty implementation + } + } + + private val listeners = mutableListOf() private val checkProgressPeriodicAction = PeriodicAction(CHECK_PROGRESS_INTERVAL) { - state.ifPresent(modelCompositeListener::onPlaybackUpdate) + notifyPlaybackUpdate() } private val playerListener = PlaybackModelPlayerListener( checkProgressPeriodicAction, - this::onPlaybackUpdate, + this::notifyPlaybackUpdate, this::onPlaybackError ) @@ -76,7 +80,7 @@ class Media3PlaybackModel @Inject constructor( controller.removeListener(playerListener) controllerScope?.cancel() checkProgressPeriodicAction.stop() - state.ifPresent(modelCompositeListener::onPlaybackUpdate) + notifyPlaybackUpdate() } } @@ -85,36 +89,39 @@ class Media3PlaybackModel @Inject constructor( private var mediaSession: MediaSession? = null - override val state: Optional - get() { - return stateFactory.create(controller) - } + val state: PlaybackState? + get() = controller?.toPlaybackState() - override fun getMediaSession(): MediaSession = mediaSession ?: mediaSessionFactory.create().also { + fun getMediaSession(): MediaSession = mediaSession ?: mediaSessionFactory.create().also { mediaSession = it } - override suspend fun start() { - controller = controllerFactory.create(controllerListener).apply { - addListener(playerListener) - setRepeatMode(playbackSettings.repeatMode) - shuffleModeEnabled = playbackSettings.isShuffle - controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - } + suspend fun start() { + val sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + controller = MediaController.Builder(context, sessionToken) + .setListener(controllerListener) + .buildAsync() + .await() + .apply { + addListener(playerListener) + setRepeatMode(playbackSettings.repeatMode) + shuffleModeEnabled = playbackSettings.isShuffle + controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + } } - override fun setFilesFlow(filesFlow: Flow) { + fun setFilesFlow(filesFlow: Flow) { controllerScope?.launch { filesFlow .catch { - modelCompositeListener.onPlaybackError(it) + notifyPlaybackError(it) release() } .collectLatest { setFiles(it) } } } - override fun setFiles(files: PlaybackFiles) { + fun setFiles(files: PlaybackFiles) { if (files.list.isEmpty()) { release() return @@ -122,7 +129,7 @@ class Media3PlaybackModel @Inject constructor( controller?.let { controller -> val currentFile = controller.currentMediaItem?.mediaMetadata?.playbackFile - val mediaItems = files.list.map(mediaItemFactory::create) + val mediaItems = files.list.map { it.toMediaItem() } if (currentFile == null) { controller.setMediaItems(mediaItems) @@ -142,65 +149,67 @@ class Media3PlaybackModel @Inject constructor( .indexOfFirst { it.id == currentFile.id } .let { if (it in 0..files.list.lastIndex) it else 0 } - override fun release() { + fun release() { controller?.release() mediaSession?.player?.release() mediaSession?.release() mediaSession = null } - override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + fun setVideoSurfaceView(surfaceView: SurfaceView?) { controller?.setVideoSurfaceView(surfaceView) } - override fun addListener(listener: PlaybackModel.Listener) { - modelCompositeListener.addListener(listener) + fun addListener(listener: Listener) { + if (!listeners.contains(listener)) { + listeners.add(listener) + } } - override fun removeListener(listener: PlaybackModel.Listener) { - modelCompositeListener.removeListener(listener) + fun removeListener(listener: Listener) { + listeners.remove(listener) } - override fun play() { + fun play() { controller?.run { prepare() play() } } - override fun pause() { + fun pause() { controller?.pause() } - override fun playNext() { + fun playNext() { controller?.run { seekToNextMediaItem() prepare() } } - override fun playPrevious() { + fun playPrevious() { controller?.run { seekToPreviousMediaItem() prepare() } } - override fun seekToPosition(positionInMilliseconds: Long) { + fun seekToPosition(positionInMilliseconds: Long) { controller?.seekTo(positionInMilliseconds) } - override fun setRepeatMode(repeatMode: RepeatMode) { + fun setRepeatMode(repeatMode: RepeatMode) { playbackSettings.setRepeatMode(repeatMode) controller?.setRepeatMode(repeatMode) } - override fun setShuffle(shuffle: Boolean) { + fun setShuffle(shuffle: Boolean) { playbackSettings.setShuffle(shuffle) controller?.shuffleModeEnabled = shuffle } - override fun switchToFile(file: PlaybackFile) { + fun switchToFile(file: PlaybackFile) { controller?.run { val mediaItemIndex = indexOfFirst { it.mediaId == file.id } if (mediaItemIndex >= 0 && mediaItemIndex != currentMediaItemIndex) { @@ -210,7 +219,7 @@ class Media3PlaybackModel @Inject constructor( } } - override fun stopPlaying(file: OCFile) { + fun stopPlaying(file: OCFile) { controller?.run { val mediaItemIndex = indexOfFirst { it.mediaId == file.localId.toString() } if (mediaItemIndex >= 0) { @@ -219,16 +228,33 @@ class Media3PlaybackModel @Inject constructor( } } - private fun onPlaybackUpdate() { - state.ifPresent(modelCompositeListener::onPlaybackUpdate) + private fun notifyPlaybackUpdate() { + val currentState = state ?: return + for (i in 0 until listeners.size) { + listeners.getOrNull(i)?.onPlaybackUpdate(currentState) + } + } + + private fun notifyPlaybackError(error: Throwable) { + for (i in 0 until listeners.size) { + listeners.getOrNull(i)?.onPlaybackError(error) + } } private fun onPlaybackError(error: Throwable) { - modelCompositeListener.onPlaybackError(error) - state.ifPresent { state -> - if (playbackErrorStrategy.switchToNextSource(error, state)) { + notifyPlaybackError(error) + state?.let { + if (shouldSwitchToNextSource(it)) { playNext() } } } + + private fun shouldSwitchToNextSource(state: PlaybackState): Boolean { + val currentFile = state.currentItemState?.file + val currentFiles = state.currentFiles + val oneFileInQueue = currentFiles.size == 1 + val endOfQueue = currentFiles.indexOf(currentFile) == currentFiles.lastIndex + return !oneFileInQueue && !endOfQueue + } } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt index 5981a564df71..99fe41b8ab0f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt @@ -7,14 +7,18 @@ package com.nextcloud.client.player.media3 +import android.app.PendingIntent import android.content.Intent +import android.os.Build import android.os.IBinder +import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSessionService -import com.nextcloud.client.player.media3.session.MediaSessionActivityFactory -import com.nextcloud.client.player.media3.session.MediaSessionHolder +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.nextcloud.client.player.ui.PlayerActivity import dagger.android.AndroidInjection import javax.inject.Inject @@ -22,10 +26,7 @@ import javax.inject.Inject class PlaybackService : MediaSessionService() { @Inject - lateinit var mediaSessionHolder: MediaSessionHolder - - @Inject - lateinit var mediaSessionActivityFactory: MediaSessionActivityFactory + lateinit var playbackModel: PlaybackModel private var bindingCount: Int = 0 @@ -35,11 +36,10 @@ class PlaybackService : MediaSessionService() { setMediaNotificationProvider(MediaNotificationProvider(this)) } - override fun onGetSession(controllerInfo: ControllerInfo): MediaSession? = mediaSessionHolder.getMediaSession() + override fun onGetSession(controllerInfo: ControllerInfo): MediaSession? = playbackModel.getMediaSession() override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - val currentMediaItem = session.player.currentMediaItem - mediaSessionActivityFactory.create(currentMediaItem)?.let(session::setSessionActivity) + createSessionActivity(session.player.currentMediaItem)?.let(session::setSessionActivity) super.onUpdateNotification(session, startInForegroundRequired) } @@ -61,12 +61,28 @@ class PlaybackService : MediaSessionService() { override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - mediaSessionHolder.release() + playbackModel.release() stopSelf() } override fun onDestroy() { - mediaSessionHolder.release() + playbackModel.release() super.onDestroy() } + + private fun createSessionActivity(currentMediaItem: MediaItem?): PendingIntent? { + val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null + val fileType = PlaybackFileType.entries + .firstOrNull { currentFile.mimeType.startsWith(it.value, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported file type: ${currentFile.mimeType}") + + val intent = PlayerActivity.createIntent(this, fileType) + val requestCode = System.currentTimeMillis().toInt() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + } } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt deleted file mode 100644 index 97b5dccfec64..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateFactory.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3 - -import androidx.media3.common.Player -import com.nextcloud.client.player.media3.common.playbackFile -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.state.PlaybackItemMetadata -import com.nextcloud.client.player.model.state.PlaybackItemState -import com.nextcloud.client.player.model.state.PlaybackState -import com.nextcloud.client.player.model.state.PlayerState -import com.nextcloud.client.player.model.state.RepeatMode -import com.nextcloud.client.player.model.state.VideoSize -import java.util.Optional -import javax.inject.Inject - -class PlaybackStateFactory @Inject constructor() { - - fun create(player: Player?): Optional { - if (player == null) { - return Optional.empty() - } - val state = PlaybackState( - currentFiles = player.getCurrentFiles(), - currentItemState = player.getCurrentItemState(), - repeatMode = player.mapRepeatMode(), - shuffle = player.shuffleModeEnabled - ) - return Optional.of(state) - } - - private fun Player.getCurrentFiles(): List = buildList { - for (i in 0 until mediaItemCount) { - val mediaItem = getMediaItemAt(i) - val playbackFile = mediaItem.mediaMetadata.playbackFile - playbackFile?.let(::add) - } - } - - private fun Player.getCurrentItemState(): PlaybackItemState? { - val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null - return PlaybackItemState( - file = currentFile, - playerState = mapPlayerState(), - metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null, - videoSize = mapVideoSize(), - currentTimeInMilliseconds = currentPosition, - maxTimeInMilliseconds = duration - ) - } - - private fun Player.mapPlayerState(): PlayerState = when (playbackState) { - Player.STATE_IDLE -> PlayerState.IDLE - Player.STATE_ENDED -> PlayerState.COMPLETED - Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED - else -> PlayerState.NONE - } - - private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata( - title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(), - artist = mediaMetadata.artist, - album = mediaMetadata.albumTitle, - genre = mediaMetadata.genre, - year = mediaMetadata.recordingYear, - description = mediaMetadata.description, - artworkData = mediaMetadata.artworkData, - artworkUri = mediaMetadata.artworkUri?.toString() - ) - - private fun Player.mapVideoSize(): VideoSize? = videoSize - .takeIf { it.width > 0 && it.height > 0 } - ?.let { VideoSize(width = it.width, height = it.height) } - - private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) { - Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> RepeatMode.OFF - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt new file mode 100644 index 000000000000..08f49811c550 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3 + +import androidx.media3.common.Player +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemMetadata +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.model.state.VideoSize + +fun Player.toPlaybackState(): PlaybackState = PlaybackState( + currentFiles = getCurrentFiles(), + currentItemState = getCurrentItemState(), + repeatMode = mapRepeatMode(), + shuffle = shuffleModeEnabled +) + +private fun Player.getCurrentFiles(): List = buildList { + for (i in 0 until mediaItemCount) { + val mediaItem = getMediaItemAt(i) + val playbackFile = mediaItem.mediaMetadata.playbackFile + playbackFile?.let(::add) + } +} + +private fun Player.getCurrentItemState(): PlaybackItemState? { + val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null + return PlaybackItemState( + file = currentFile, + playerState = mapPlayerState(), + metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null, + videoSize = mapVideoSize(), + currentTimeInMilliseconds = currentPosition, + maxTimeInMilliseconds = duration + ) +} + +private fun Player.mapPlayerState(): PlayerState = when (playbackState) { + Player.STATE_IDLE -> PlayerState.IDLE + Player.STATE_ENDED -> PlayerState.COMPLETED + Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + else -> PlayerState.NONE +} + +private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata( + title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(), + artist = mediaMetadata.artist, + album = mediaMetadata.albumTitle, + genre = mediaMetadata.genre, + year = mediaMetadata.recordingYear, + description = mediaMetadata.description, + artworkData = mediaMetadata.artworkData, + artworkUri = mediaMetadata.artworkUri?.toString() +) + +private fun Player.mapVideoSize(): VideoSize? = videoSize + .takeIf { it.width > 0 && it.height > 0 } + ?.let { VideoSize(width = it.width, height = it.height) } + +private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) { + Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> RepeatMode.OFF +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt new file mode 100644 index 000000000000..9196991d9809 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.common + +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import com.nextcloud.client.player.model.file.PlaybackFile + +fun PlaybackFile.toMediaItem(): MediaItem = MediaItem + .Builder() + .setMediaId(id) + .setUri(uri) + .setMediaMetadata(MediaMetadata.Builder().setExtras(this).build()) + .setMimeType(mimeType) + .build() diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt deleted file mode 100644 index d3ac79d1eba2..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItemFactory.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.common - -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import com.nextcloud.client.player.model.file.PlaybackFile -import javax.inject.Inject - -class MediaItemFactory @Inject constructor() { - - fun create(file: PlaybackFile): MediaItem = MediaItem - .Builder() - .setMediaId(file.id) - .setUri(file.uri) - .setMediaMetadata(createMetadata(file)) - .setMimeType(file.mimeType) - .build() - - private fun createMetadata(file: PlaybackFile): MediaMetadata = MediaMetadata - .Builder() - .setExtras(file) - .build() -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt similarity index 97% rename from app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt rename to app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt index d619784ae62e..4680242237b2 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaController.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.client.player.media3.controller +package com.nextcloud.client.player.media3.common import androidx.media3.common.MediaItem import androidx.media3.common.Player diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt deleted file mode 100644 index cedadc1dcbf3..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/PlayerFactory.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.common - -import androidx.media3.common.Player - -interface PlayerFactory { - fun create(): Player -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt deleted file mode 100644 index f0eaf1b8d19a..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/controller/DefaultMediaControllerFactory.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.controller - -import android.content.ComponentName -import android.content.Context -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken -import com.nextcloud.client.player.media3.PlaybackService -import kotlinx.coroutines.guava.await -import javax.inject.Inject - -@UnstableApi -class DefaultMediaControllerFactory @Inject constructor(private val context: Context) : MediaControllerFactory { - - override suspend fun create(controllerListener: MediaController.Listener): MediaController { - val token = SessionToken(context, ComponentName(context, PlaybackService::class.java)) - return MediaController.Builder(context, token) - .setListener(controllerListener) - .buildAsync() - .await() - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt deleted file mode 100644 index 0ac31b8b174f..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/controller/MediaControllerFactory.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.controller - -import androidx.media3.session.MediaController - -interface MediaControllerFactory { - suspend fun create(controllerListener: MediaController.Listener): MediaController -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt similarity index 76% rename from app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt rename to app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt index 1d0158055a6a..95f74088c8b5 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSource.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt @@ -8,6 +8,7 @@ package com.nextcloud.client.player.media3.datasource import android.net.Uri +import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec @@ -17,14 +18,12 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.files.StreamMediaFileOperation import com.owncloud.android.lib.common.OwnCloudClient import java.io.IOException -import androidx.core.net.toUri @UnstableApi -class DefaultDataSource( +class PlaybackDataSource( private val delegate: DataSource, private val fileDataStorageManager: FileDataStorageManager, - private val ownCloudClient: OwnCloudClient, - private val streamOperationFactory: StreamMediaFileOperationFactory = DefaultStreamMediaFileOperationFactory() + private val ownCloudClient: OwnCloudClient ) : DataSource by delegate { override fun getResponseHeaders() = delegate.responseHeaders @@ -45,8 +44,7 @@ class DefaultDataSource( } private fun openRemoteFile(dataSpec: DataSpec, fileId: Long): Long { - val streamMediaFileOperation = streamOperationFactory.create(fileId) - val result = streamMediaFileOperation.execute(ownCloudClient) + val result = StreamMediaFileOperation(fileId).execute(ownCloudClient) return if (result.isSuccess) { val uri = (result.data[0] as? String)?.toUri() ?: throw IllegalStateException("url is not valid, cannot stream") @@ -58,11 +56,3 @@ class DefaultDataSource( private fun DataSpec.buildUpon(uri: Uri) = buildUpon().setUri(uri).build() } - -interface StreamMediaFileOperationFactory { - fun create(fileId: Long): StreamMediaFileOperation -} - -class DefaultStreamMediaFileOperationFactory : StreamMediaFileOperationFactory { - override fun create(fileId: Long) = StreamMediaFileOperation(fileId) -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt similarity index 95% rename from app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt rename to app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt index d3221d6da1e1..40c0af28912f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/DefaultDataSourceFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt @@ -22,7 +22,7 @@ import com.owncloud.android.datamodel.FileDataStorageManager import javax.inject.Inject @UnstableApi -class DefaultDataSourceFactory @Inject constructor( +class PlaybackDataSourceFactory @Inject constructor( private val context: Context, private val cache: Cache, private val fileDataStorageManager: FileDataStorageManager, @@ -36,7 +36,7 @@ class DefaultDataSourceFactory @Inject constructor( .createDataSource() private fun createUpstreamDataSourceFactory() = DataSource.Factory { - DefaultDataSource( + PlaybackDataSource( delegate = DefaultDataSource.Factory(context, createHttpDataSourceFactory()).createDataSource(), fileDataStorageManager = fileDataStorageManager, ownCloudClient = clientFactory.create(accountManager.user) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt index 6d3a99e877ce..e6f05124c57f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt @@ -53,12 +53,6 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con } } - fun clear() { - preferences.edit { - clear() - } - } - private fun playbackFileType(value: String): PlaybackFileType? = PlaybackFileType.entries.firstOrNull { it.value == value } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt index 16f1aab6db59..b3a6d281411b 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt @@ -9,8 +9,8 @@ package com.nextcloud.client.player.media3.resumption import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession.MediaItemsWithStartPosition -import com.nextcloud.client.player.media3.common.MediaItemFactory -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.media3.common.toMediaItem import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.file.PlaybackFilesRepository import com.nextcloud.client.player.model.file.getPlaybackUri @@ -25,7 +25,6 @@ import javax.inject.Inject class PlaybackResumptionLauncher @Inject constructor( private val playbackResumptionConfigStore: PlaybackResumptionConfigStore, private val playbackFilesRepository: PlaybackFilesRepository, - private val mediaItemFactory: MediaItemFactory, private val playbackModel: PlaybackModel ) { @@ -52,7 +51,7 @@ class PlaybackResumptionLauncher @Inject constructor( } private fun List.toMediaItemsWithStartPosition(currentFileId: String) = MediaItemsWithStartPosition( - map { mediaItemFactory.create(it) }, + map { it.toMediaItem() }, indexOfFirst { it.id == currentFileId }, 0 ) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt deleted file mode 100644 index 6b8f8d4ef00e..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionPlayerListener.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.resumption - -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import javax.inject.Inject - -class PlaybackResumptionPlayerListener @Inject constructor( - private val playbackResumptionConfigStore: PlaybackResumptionConfigStore -) : Player.Listener { - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - mediaItem?.let { playbackResumptionConfigStore.updateCurrentFileId(it.mediaId) } - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt deleted file mode 100644 index 89e47a5fc98c..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/DefaultMediaSessionFactory.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.session - -import android.content.Context -import android.os.Bundle -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.CommandButton -import androidx.media3.session.MediaSession -import androidx.media3.session.SessionCommand -import com.nextcloud.client.player.media3.common.PlayerFactory -import com.nextcloud.client.player.media3.resumption.PlaybackResumptionPlayerListener -import com.owncloud.android.R -import javax.inject.Inject - -@UnstableApi -class DefaultMediaSessionFactory @Inject constructor( - private val context: Context, - private val playerFactory: PlayerFactory, - private val sessionCallback: MediaSessionCallback, - private val resumptionPlayerListener: PlaybackResumptionPlayerListener, - private val bitmapLoader: MediaSessionBitmapLoader -) : MediaSessionFactory { - - override fun create(): MediaSession { - val player = playerFactory.create() - player.addListener(resumptionPlayerListener) - return MediaSession - .Builder(context, player) - .setBitmapLoader(bitmapLoader) - .setCallback(sessionCallback) - .setCustomLayout(provideCustomLayout()) - .build() - } - - private fun provideCustomLayout(): List = listOf( - CommandButton - .Builder() - .setDisplayName(context.getString(R.string.player_media_controls_close_action_title)) - .setIconResId(R.drawable.player_ic_close) - .setSessionCommand(SessionCommand(MediaSessionCallback.CLOSE_ACTION, Bundle.EMPTY)) - .build() - ) -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt deleted file mode 100644 index f0309fd1cb46..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionActivityFactory.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.session - -import android.app.PendingIntent -import android.content.Context -import android.os.Build -import androidx.media3.common.MediaItem -import com.nextcloud.client.player.media3.common.playbackFile -import com.nextcloud.client.player.model.file.PlaybackFileType -import com.nextcloud.client.player.ui.PlayerActivity -import javax.inject.Inject - -class MediaSessionActivityFactory @Inject constructor(private val context: Context) { - - fun create(currentMediaItem: MediaItem?): PendingIntent? { - val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null - val fileType = PlaybackFileType.entries - .firstOrNull { currentFile.mimeType.startsWith(it.value, ignoreCase = true) } - ?: throw IllegalArgumentException("Unsupported file type: ${currentFile.mimeType}") - - val intent = PlayerActivity.createIntent(context, fileType) - - val requestCode = System.currentTimeMillis().toInt() - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getActivity(context, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) - } else { - PendingIntent.getActivity(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT) - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt index 7291e47f0fae..cd94c01708f4 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -17,7 +17,7 @@ import androidx.media3.session.SessionResult import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.nextcloud.client.player.media3.resumption.PlaybackResumptionLauncher -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.guava.future import javax.inject.Inject diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt index f08b998b60de..8892293e50aa 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt @@ -7,8 +7,63 @@ package com.nextcloud.client.player.media3.session +import android.content.Context +import android.os.Bundle +import androidx.media3.common.AudioAttributes +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import com.nextcloud.client.player.media3.datasource.PlaybackDataSourceFactory +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore +import com.owncloud.android.R +import javax.inject.Inject -interface MediaSessionFactory { - fun create(): MediaSession +private const val SEEK_FORWARD_INCREMENT_IN_MILLISECONDS = 5000L + +@UnstableApi +class MediaSessionFactory @Inject constructor( + private val context: Context, + private val dataSourceFactory: PlaybackDataSourceFactory, + private val sessionCallback: MediaSessionCallback, + private val resumptionConfigStore: PlaybackResumptionConfigStore, + private val bitmapLoader: MediaSessionBitmapLoader +) { + + private val resumptionPlayerListener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + mediaItem?.let { resumptionConfigStore.updateCurrentFileId(it.mediaId) } + } + } + + fun create(): MediaSession { + val player = createPlayer() + player.addListener(resumptionPlayerListener) + return MediaSession + .Builder(context, player) + .setBitmapLoader(bitmapLoader) + .setCallback(sessionCallback) + .setCustomLayout(createCustomLayout()) + .build() + } + + private fun createPlayer(): Player = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setSeekForwardIncrementMs(SEEK_FORWARD_INCREMENT_IN_MILLISECONDS) + .build() + + private fun createCustomLayout(): List = listOf( + CommandButton + .Builder() + .setDisplayName(context.getString(R.string.player_media_controls_close_action_title)) + .setIconResId(R.drawable.player_ic_close) + .setSessionCommand(SessionCommand(MediaSessionCallback.CLOSE_ACTION, Bundle.EMPTY)) + .build() + ) } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt deleted file mode 100644 index 5387140d3a66..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionHolder.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.session - -import androidx.media3.session.MediaSession - -interface MediaSessionHolder { - - fun getMediaSession(): MediaSession - - fun release() -} diff --git a/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt deleted file mode 100644 index 8e41331ec4e8..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/model/GlideThumbnailLoader.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model - -import android.content.Context -import android.graphics.Bitmap -import android.widget.ImageView -import com.bumptech.glide.Glide -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.load.model.LazyHeaders -import com.bumptech.glide.signature.ObjectKey -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.client.player.model.file.PlaybackFile -import com.owncloud.android.MainApp -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import java.util.concurrent.Future -import javax.inject.Inject -import kotlin.coroutines.resume - -class GlideThumbnailLoader @Inject constructor(clientFactory: ClientFactory, userAccountManager: UserAccountManager) : - ThumbnailLoader { - private val client by lazy { clientFactory.createNextcloudClient(userAccountManager.user) } - - override suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? = - withContext(Dispatchers.IO) { - suspendCancellableCoroutine { continuation -> - runCatching { - val future = load(context, file, width, height) - continuation.invokeOnCancellation { future.cancel(true) } - continuation.resume(future.get()) - }.onFailure { - if (it is CancellationException) throw it - continuation.resume(null) - } - } - } - - override fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future { - val url = createUrl(file, width, height) - return load(context, url, file.id, width, height) - } - - override fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future = Glide - .with(context) - .asBitmap() - .load(model) - .signature(ObjectKey(fileId ?: model.toString())) - .submit(width, height) - - override fun load(imageView: ImageView, model: Any, fileId: String) { - Glide - .with(imageView) - .load(model) - .signature(ObjectKey(fileId)) - .into(imageView) - } - - private fun createUrl(file: PlaybackFile, width: Int, height: Int) = GlideUrl( - "${client.baseUri}/index.php/core/preview?fileId=${file.id}&x=$width&y=$height&a=1&mode=cover&forceIcon=0", - LazyHeaders.Builder() - .addHeader("Authorization", client.credentials) - .addHeader("User-Agent", MainApp.getUserAgent()) - .build() - ) -} diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt deleted file mode 100644 index 626227500220..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModel.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model - -import android.view.SurfaceView -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.file.PlaybackFiles -import com.nextcloud.client.player.model.state.PlaybackState -import com.nextcloud.client.player.model.state.RepeatMode -import com.owncloud.android.datamodel.OCFile -import kotlinx.coroutines.flow.Flow -import java.util.Optional - -@Suppress("TooManyFunctions") -interface PlaybackModel { - - val state: Optional - - suspend fun start() - - fun setFilesFlow(filesFlow: Flow) - - fun setFiles(files: PlaybackFiles) - - fun release() - - fun setVideoSurfaceView(surfaceView: SurfaceView?) - - fun addListener(listener: Listener) - - fun removeListener(listener: Listener) - - fun play() - - fun pause() - - fun playNext() - - fun playPrevious() - - fun seekToPosition(positionInMilliseconds: Long) - - fun setRepeatMode(repeatMode: RepeatMode) - - fun setShuffle(shuffle: Boolean) - - fun switchToFile(file: PlaybackFile) - - fun stopPlaying(file: OCFile) - - interface Listener { - - fun onPlaybackUpdate(state: PlaybackState) - - fun onPlaybackError(error: Throwable) { - // Default empty implementation - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt deleted file mode 100644 index c97987d43979..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/model/PlaybackModelCompositeListener.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model - -import com.nextcloud.client.player.model.state.PlaybackState - -class PlaybackModelCompositeListener : PlaybackModel.Listener { - private val listeners = mutableListOf() - - fun addListener(listener: PlaybackModel.Listener) { - if (!listeners.contains(listener)) { - listeners.add(listener) - } - } - - fun removeListener(listener: PlaybackModel.Listener?) { - listeners.remove(listener) - } - - override fun onPlaybackUpdate(state: PlaybackState) { - for (i in 0 until listeners.size) { - listeners.getOrNull(i)?.onPlaybackUpdate(state) - } - } - - override fun onPlaybackError(error: Throwable) { - for (i in 0 until listeners.size) { - listeners.getOrNull(i)?.onPlaybackError(error) - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt index 1bfa906b4c21..9b8c7a97a2c3 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt @@ -41,10 +41,4 @@ class PlaybackSettings @Inject constructor(context: Context) { putBoolean(SHUFFLE_KEY, shuffle) } } - - fun reset() { - preferences.edit { - clear() - } - } } diff --git a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt index a2e997292fda..74b2ebeb0c23 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt @@ -10,16 +10,64 @@ package com.nextcloud.client.player.model import android.content.Context import android.graphics.Bitmap import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.LazyHeaders +import com.bumptech.glide.signature.ObjectKey +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory import com.nextcloud.client.player.model.file.PlaybackFile +import com.owncloud.android.MainApp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import java.util.concurrent.Future +import javax.inject.Inject +import kotlin.coroutines.resume -interface ThumbnailLoader { +class ThumbnailLoader @Inject constructor(clientFactory: ClientFactory, userAccountManager: UserAccountManager) { + private val client by lazy { clientFactory.createNextcloudClient(userAccountManager.user) } - suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? + suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? = + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + runCatching { + val future = load(context, file, width, height) + continuation.invokeOnCancellation { future.cancel(true) } + continuation.resume(future.get()) + }.onFailure { + if (it is CancellationException) throw it + continuation.resume(null) + } + } + } - fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future + fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future { + val url = createUrl(file, width, height) + return load(context, url, file.id, width, height) + } - fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future + fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future = Glide + .with(context) + .asBitmap() + .load(model) + .signature(ObjectKey(fileId ?: model.toString())) + .submit(width, height) - fun load(imageView: ImageView, model: Any, fileId: String) + fun load(imageView: ImageView, model: Any, fileId: String) { + Glide + .with(imageView) + .load(model) + .signature(ObjectKey(fileId)) + .into(imageView) + } + + private fun createUrl(file: PlaybackFile, width: Int, height: Int) = GlideUrl( + "${client.baseUri}/index.php/core/preview?fileId=${file.id}&x=$width&y=$height&a=1&mode=cover&forceIcon=0", + LazyHeaders.Builder() + .addHeader("Authorization", client.credentials) + .addHeader("User-Agent", MainApp.getUserAgent()) + .build() + ) } diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt b/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt deleted file mode 100644 index 4badebd01016..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/model/error/DefaultPlaybackErrorStrategy.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model.error - -import com.nextcloud.client.player.model.state.PlaybackState -import javax.inject.Inject - -class DefaultPlaybackErrorStrategy @Inject constructor() : PlaybackErrorStrategy { - - override fun switchToNextSource(error: Throwable, state: PlaybackState): Boolean { - val currentFile = state.currentItemState?.file - val currentFiles = state.currentFiles - val oneFileInQueue = currentFiles.size == 1 - val endOfQueue = currentFiles.indexOf(currentFile) == currentFiles.lastIndex - return !oneFileInQueue && !endOfQueue - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt b/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt deleted file mode 100644 index 357a6e930876..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/model/error/PlaybackErrorStrategy.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model.error - -import com.nextcloud.client.player.model.state.PlaybackState -import java.io.Serializable - -interface PlaybackErrorStrategy : Serializable { - fun switchToNextSource(error: Throwable, state: PlaybackState): Boolean -} diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt index ab151fd9f2f1..3914ce80f0ab 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt @@ -9,5 +9,11 @@ package com.nextcloud.client.player.model.file enum class PlaybackFileType(val value: String) { AUDIO("audio"), - VIDEO("video") + VIDEO("video"); + + companion object { + fun ofMimeType(mimeType: String): PlaybackFileType = entries + .firstOrNull { mimeType.startsWith(it.value, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported file type: $mimeType") + } } diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt index 674d010b24e9..4465abb8a212 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt @@ -29,19 +29,16 @@ sealed interface PlaybackFilesComparator : Comparator { } data class Folder(val sortType: FileSortOrder.SortType, val isAscending: Boolean) : PlaybackFilesComparator { - private val delegate = createDelegate() + private val sortTypeComparator: Comparator = when (sortType) { + FileSortOrder.SortType.ALPHABET -> Comparator { a, b -> AlphanumericComparator.compare(a.name, b.name) } + FileSortOrder.SortType.SIZE -> compareBy { it.contentLength } + FileSortOrder.SortType.DATE -> compareBy { it.lastModified } + } - override fun compare(a: PlaybackFile, b: PlaybackFile): Int = delegate.compare(a, b) + private val delegate = compareByDescending(PlaybackFile::isFavorite) + .thenComparing(if (isAscending) sortTypeComparator else sortTypeComparator.reversed()) - private fun createDelegate(): Comparator { - val sortTypeComparator: Comparator = when (sortType) { - FileSortOrder.SortType.ALPHABET -> Comparator { a, b -> AlphanumericComparator.compare(a.name, b.name) } - FileSortOrder.SortType.SIZE -> compareBy { it.contentLength } - FileSortOrder.SortType.DATE -> compareBy { it.lastModified } - } - return compareByDescending(PlaybackFile::isFavorite) - .thenComparing(if (isAscending) sortTypeComparator else sortTypeComparator.reversed()) - } + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = delegate.compare(a, b) } } diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt index 5ecdb55d282d..e94a9d7e3be7 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt @@ -16,38 +16,25 @@ import com.owncloud.android.MainApp import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.db.ProviderMeta.ProviderTableMeta import com.owncloud.android.ui.fragment.SearchType -import com.owncloud.android.utils.FileSortOrder -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext import javax.inject.Inject -class PlaybackFilesRepository( +private const val FETCH_DATA_DEBOUNCE_MS = 250L + +class PlaybackFilesRepository @Inject constructor( + private val context: Context, private val storageManager: FileDataStorageManager, - private val preferences: AppPreferences, - private val dispatcher: CoroutineDispatcher, - private val contentObserver: (uri: Uri, notifyForDescendants: Boolean) -> Flow + private val preferences: AppPreferences ) { - @Inject - constructor(context: Context, storageManager: FileDataStorageManager, preferences: AppPreferences) : this( - storageManager, - preferences, - Dispatchers.IO, - context.contentResolver::observeContentChanges - ) - - companion object { - private const val FETCH_DATA_DEBOUNCE_MS = 250L - } - fun observe(folderId: Long, fileType: PlaybackFileType, searchType: SearchType?): Flow = when (searchType) { SearchType.FAVORITE_SEARCH -> observeFavoritePlaybackFiles(fileType) @@ -56,103 +43,70 @@ class PlaybackFilesRepository( else -> observeFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice()) } - suspend fun get(folderId: Long, fileType: PlaybackFileType, searchType: SearchType?): PlaybackFiles = - when (searchType) { - SearchType.FAVORITE_SEARCH -> getFavoritePlaybackFiles(fileType) - SearchType.GALLERY_SEARCH -> getGalleryPlaybackFiles(fileType) - SearchType.SHARED_FILTER -> getSharedPlaybackFiles(fileType) - else -> getFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice()) - } - - private fun observeFavoritePlaybackFiles(fileType: PlaybackFileType): Flow { - val uri = ProviderTableMeta.CONTENT_URI - return observeData(uri, true) { - getFavoritePlaybackFiles(fileType) + private fun observeFavoritePlaybackFiles(fileType: PlaybackFileType): Flow = + observeData(ProviderTableMeta.CONTENT_URI, true) { + withContext(Dispatchers.IO) { + storageManager.favoriteFiles + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(PlaybackFilesComparator.FAVORITE) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.FAVORITE) } + } } - } - private suspend fun getFavoritePlaybackFiles(fileType: PlaybackFileType): PlaybackFiles = withContext(dispatcher) { - storageManager.favoriteFiles - .asSequence() - .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } - .map { it.toPlaybackFile() } - .sortedWith(PlaybackFilesComparator.FAVORITE) - .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.FAVORITE) } - } - - private fun observeGalleryPlaybackFiles(fileType: PlaybackFileType): Flow { - val uri = ProviderTableMeta.CONTENT_URI - return observeData(uri, true) { - getGalleryPlaybackFiles(fileType) + private fun observeGalleryPlaybackFiles(fileType: PlaybackFileType): Flow = + observeData(ProviderTableMeta.CONTENT_URI, true) { + withContext(Dispatchers.IO) { + storageManager.allGalleryItems + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(PlaybackFilesComparator.GALLERY) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.GALLERY) } + } } - } - - private suspend fun getGalleryPlaybackFiles(fileType: PlaybackFileType): PlaybackFiles = withContext(dispatcher) { - storageManager.allGalleryItems - .asSequence() - .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } - .map { it.toPlaybackFile() } - .sortedWith(PlaybackFilesComparator.GALLERY) - .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.GALLERY) } - } - private fun observeSharedPlaybackFiles(fileType: PlaybackFileType): Flow { - val uri = ProviderTableMeta.CONTENT_URI_SHARE - return observeData(uri, false) { - getSharedPlaybackFiles(fileType) + private fun observeSharedPlaybackFiles(fileType: PlaybackFileType): Flow = + observeData(ProviderTableMeta.CONTENT_URI_SHARE, false) { + withContext(Dispatchers.IO) { + storageManager.shares + .asSequence() + .distinctBy { it.fileSource } + .map { it.toPlaybackFile() } + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .sortedWith(PlaybackFilesComparator.SHARED) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.SHARED) } + } } - } - - private suspend fun getSharedPlaybackFiles(fileType: PlaybackFileType): PlaybackFiles = withContext(dispatcher) { - storageManager.shares - .asSequence() - .distinctBy { it.fileSource } - .map { it.toPlaybackFile() } - .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } - .sortedWith(PlaybackFilesComparator.SHARED) - .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.SHARED) } - } private fun observeFolderPlaybackFiles( folderId: Long, fileType: PlaybackFileType, onDeviceOnly: Boolean - ): Flow { + ): Flow = flow { val uri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, folderId) - val sortOrderFlow = flow { - emit(getFolderSortOrder(folderId)) + val comparator = withContext(Dispatchers.IO) { + preferences.getSortOrderByFolder(getFolder(folderId)).toPlaybackFilesComparator() } - return sortOrderFlow.flatMapConcat { sortOrder -> - val comparator = sortOrder.toPlaybackFilesComparator() - observeData(uri, false) { - getFolderPlaybackFiles(folderId, fileType, onDeviceOnly, comparator) + val playbackFiles = observeData(uri, false) { + withContext(Dispatchers.IO) { + storageManager.getFolderContent(getFolder(folderId), onDeviceOnly) + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(comparator) + .let { PlaybackFiles(it.toList(), comparator) } } } + emitAll(playbackFiles) } - private suspend fun getFolderPlaybackFiles( - folderId: Long, - fileType: PlaybackFileType, - onDeviceOnly: Boolean, - comparator: PlaybackFilesComparator? = null - ): PlaybackFiles = withContext(dispatcher) { - val folder = storageManager.getFileById(folderId) ?: throw IllegalStateException("Folder not found") - val comparator = comparator ?: preferences.getSortOrderByFolder(folder).toPlaybackFilesComparator() - storageManager.getFolderContent(folder, onDeviceOnly) - .asSequence() - .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } - .map { it.toPlaybackFile() } - .sortedWith(comparator) - .let { PlaybackFiles(it.toList(), comparator) } - } - - private suspend fun getFolderSortOrder(folderId: Long): FileSortOrder = withContext(dispatcher) { - val folder = storageManager.getFileById(folderId) ?: throw IllegalStateException("Folder not found") - preferences.getSortOrderByFolder(folder) - } + private fun getFolder(folderId: Long) = storageManager.getFileById(folderId) + ?: throw IllegalStateException("Folder not found") private fun observeData(uri: Uri, notifyForDescendants: Boolean, fetchData: suspend () -> T): Flow = - contentObserver(uri, notifyForDescendants) + context.contentResolver.observeContentChanges(uri, notifyForDescendants) .debounce(FETCH_DATA_DEBOUNCE_MS) // Debounce to avoid too frequent data fetching for batch updates .map { fetchData() } .onStart { emit(fetchData()) } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 50c063d9828a..1527c09911f5 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.nextcloud.client.di.Injectable +import com.nextcloud.client.di.ViewModelFactory import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.ui.audio.AudioPlayerView import com.nextcloud.client.player.ui.video.VideoPlayerView @@ -62,7 +63,7 @@ class PlayerActivity : } @Inject - lateinit var viewModelFactory: PlayerViewModel.Factory + lateinit var viewModelFactory: ViewModelFactory private val viewModel by viewModels { viewModelFactory } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt index 295ad1ef4312..56339aae0314 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt @@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.nextcloud.client.logger.Logger import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.file.PlaybackFilesComparator @@ -36,7 +36,7 @@ class PlayerLauncher @Inject constructor( currentLaunchJob?.cancel() currentLaunchJob = activity.lifecycleScope.launch { runCatching { - val fileType = file.getPlaybackFileType() + val fileType = PlaybackFileType.ofMimeType(file.mimeType) playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, searchType) val currentPlaybackFile = file.toPlaybackFile() @@ -54,8 +54,4 @@ class PlayerLauncher @Inject constructor( } } } - - private fun OCFile.getPlaybackFileType(): PlaybackFileType = PlaybackFileType.entries - .firstOrNull { mimeType.startsWith(it.value, ignoreCase = true) } - ?: throw IllegalArgumentException("Unsupported file type: $mimeType") } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt index f34284b36c6b..d70435d7d34d 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt @@ -11,7 +11,7 @@ import android.content.Context import android.util.AttributeSet import androidx.annotation.AttrRes import com.google.android.material.progressindicator.LinearProgressIndicator -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.file.toPlaybackFile import com.nextcloud.client.player.model.state.PlaybackItemState @@ -20,7 +20,6 @@ import com.nextcloud.client.player.model.state.PlayerState import com.owncloud.android.datamodel.OCFile import dagger.android.HasAndroidInjector import javax.inject.Inject -import kotlin.jvm.optionals.getOrNull class PlayerProgressIndicator @JvmOverloads constructor( context: Context, @@ -69,7 +68,7 @@ class PlayerProgressIndicator @JvmOverloads constructor( } private fun renderCurrentState() { - val itemState = playbackModel.state.getOrNull()?.currentItemState + val itemState = playbackModel.state?.currentItemState render(itemState) } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index d9b46fe8c8de..5c4fdf1f9ad3 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -15,17 +15,16 @@ import android.widget.TextView import androidx.annotation.CallSuper import androidx.annotation.LayoutRes import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.jobs.download.FileDownloadHelper -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.error.SourceException import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.ui.control.PlayerControlView import com.nextcloud.client.player.ui.pager.PlayerPager -import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory -import com.nextcloud.client.player.ui.pager.PlayerPagerMode import com.nextcloud.client.player.util.WindowWrapper import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager @@ -33,11 +32,11 @@ import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.DownloadFileOperation import com.owncloud.android.utils.DisplayUtils +import dagger.android.HasAndroidInjector import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject -import kotlin.jvm.optionals.getOrNull abstract class PlayerView @JvmOverloads constructor( private val context: Context, @@ -59,31 +58,29 @@ abstract class PlayerView @JvmOverloads constructor( @get:LayoutRes protected abstract val layoutRes: Int - protected abstract val fragmentFactory: PlayerPagerFragmentFactory + protected abstract val createFragment: (PlaybackFile) -> Fragment protected val activity: AppCompatActivity by lazy { context as AppCompatActivity } protected val windowWrapper: WindowWrapper by lazy { WindowWrapper(activity.window) } protected val topBar: View by lazy { findViewById(R.id.topBar) } protected val titleTextView: TextView by lazy { findViewById(R.id.title) } - protected val playerPager: PlayerPager by lazy { findViewById(R.id.playerPager) } + protected val playerPager: PlayerPager by lazy { findViewById(R.id.playerPager) } protected val playerControlView: PlayerControlView by lazy { findViewById(R.id.playerControlView) } init { inflate(context, layoutRes, this) if (!isInEditMode) { - inject(context) - playerPager.initialize(activity.supportFragmentManager, PlayerPagerMode.INFINITE, fragmentFactory) - playerPager.setPlayerPagerListener { playbackModel.switchToFile(it) } + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + playerPager.initialize(activity.supportFragmentManager, createFragment) + playerPager.onItemSelected = { playbackModel.switchToFile(it) } findViewById(R.id.back).setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() } } } - protected abstract fun inject(context: Context) - @CallSuper open fun onStart() { - val state = playbackModel.state.getOrNull() + val state = playbackModel.state if (state == null) { activity.finish() return @@ -113,7 +110,7 @@ abstract class PlayerView @JvmOverloads constructor( } private fun downloadFile() { - val currentFile = playbackModel.state.getOrNull()?.currentItemState?.file + val currentFile = playbackModel.state?.currentItemState?.file val storageManager = FileDataStorageManager(userAccountManager.user, context.contentResolver) val file = currentFile?.id?.toLong()?.let { storageManager.getFileByLocalId(it) } ?: return diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt index 1c6849f1d18c..df14c565c931 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt @@ -9,13 +9,12 @@ package com.nextcloud.client.player.ui import androidx.core.text.isDigitsOnly import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.jobs.BackgroundJobManager import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.logger.Logger -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile @@ -26,9 +25,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject -import javax.inject.Provider import kotlin.coroutines.cancellation.CancellationException -import kotlin.jvm.optionals.getOrNull class PlayerViewModel @Inject constructor( private val playbackModel: PlaybackModel, @@ -70,7 +67,7 @@ class PlayerViewModel @Inject constructor( } private suspend fun getCurrentOCFile(): OCFile? { - val currentFileId = playbackModel.state.getOrNull()?.currentItemState?.file?.id + val currentFileId = playbackModel.state?.currentItemState?.file?.id return currentFileId ?.takeIf { it.isDigitsOnly() } ?.let { getOCFile(it.toLong()) } @@ -105,10 +102,4 @@ class PlayerViewModel @Inject constructor( playbackModel.pause() eventChannel.trySend(PlayerScreenEvent.LaunchStreamFileIntent(file)) } - - class Factory @Inject constructor(private val viewModelProvider: Provider) : - ViewModelProvider.Factory { - - override fun create(modelClass: Class): T = viewModelProvider.get() as T - } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt index a1c7dd213741..790d4d5bf9a9 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -14,7 +14,7 @@ import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.state.PlaybackItemMetadata @@ -67,7 +67,7 @@ open class AudioFileFragment : override fun onStart() { super.onStart() - playbackModel.state.ifPresent(::onPlaybackUpdate) + playbackModel.state?.let(::onPlaybackUpdate) playbackModel.addListener(this) } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt deleted file mode 100644 index 2b1b39769478..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragmentFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.audio - -import androidx.fragment.app.Fragment -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory - -class AudioFileFragmentFactory : PlayerPagerFragmentFactory { - - override fun create(item: PlaybackFile): Fragment = AudioFileFragment.createInstance(item) -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt index c4202c11019f..2681412967a7 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt @@ -11,19 +11,16 @@ import android.content.Context import android.view.WindowInsets import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type +import androidx.fragment.app.Fragment +import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.ui.PlayerView import com.owncloud.android.R -import dagger.android.HasAndroidInjector class AudioPlayerView(context: Context) : PlayerView(context) { override val layoutRes get() = R.layout.player_audio_view - override val fragmentFactory get() = AudioFileFragmentFactory() - - override fun inject(context: Context) { - (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) - } + override val createFragment: (PlaybackFile) -> Fragment get() = { AudioFileFragment.createInstance(it) } override fun onStart() { super.onStart() diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt index a5eb7fb49c8b..39957620b263 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt @@ -10,42 +10,32 @@ package com.nextcloud.client.player.ui.control import android.os.Handler import android.os.Looper import android.view.View -import java.util.Optional -abstract class MultipleClickListener : View.OnClickListener { +private const val TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS = 250L +private const val SINGLE_CLICK_COUNT = 1 - companion object { - private const val TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS = 250L - } +class MultipleClickListener(private val onSingleClick: () -> Unit, private val onDoubleClick: () -> Unit) : + View.OnClickListener { private val handler = Handler(Looper.getMainLooper()) - private var clicksCount = Optional.empty() - - protected abstract fun onSingleClick(view: View?) - - protected abstract fun onDoubleClick(view: View?) + private var clicksCount: Int? = null override fun onClick(view: View?) { - val interactionIsBegan = clicksCount.isPresent - - if (interactionIsBegan) { - clicksCount = Optional.of(clicksCount.get() + 1) - } else { - clicksCount = Optional.of(1) - - handler.postDelayed({ - val count = clicksCount.get() - clicksCount = Optional.empty() - callSubscriber(view, count) - }, TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS) + val pendingClicksCount = clicksCount + if (pendingClicksCount != null) { + clicksCount = pendingClicksCount + 1 + return } - } - private fun callSubscriber(view: View?, clicksCount: Int) { - if (clicksCount == 1) { - onSingleClick(view) - } else { - onDoubleClick(view) - } + clicksCount = SINGLE_CLICK_COUNT + handler.postDelayed({ + val count = clicksCount ?: SINGLE_CLICK_COUNT + clicksCount = null + if (count == SINGLE_CLICK_COUNT) { + onSingleClick() + } else { + onDoubleClick() + } + }, TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS) } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index fa11e66df09f..86baf378b418 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -10,7 +10,6 @@ package com.nextcloud.client.player.ui.control import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View import android.widget.LinearLayout import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener @@ -19,7 +18,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.flowWithLifecycle -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.state.PlaybackItemState import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.PlayerState @@ -37,7 +36,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject -import kotlin.jvm.optionals.getOrNull private const val INDETERMINATE_TIME = "--:--" private const val TAG_CLICK_COMMAND_PLAY = "TAG_CLICK_COMMAND_PLAY" @@ -59,8 +57,7 @@ private const val MINUTES_IN_HOUR = 60 class PlayerControlView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - injectedPlaybackModel: PlaybackModel? = null + defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr), PlaybackModel.Listener { @@ -74,11 +71,7 @@ class PlayerControlView @JvmOverloads constructor( init { if (!isInEditMode) { - if (injectedPlaybackModel == null) { - (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) - } else { - playbackModel = injectedPlaybackModel - } + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) setDefaultTags() setListeners() } @@ -101,7 +94,7 @@ class PlayerControlView @JvmOverloads constructor( } fun onStart() { - playbackModel.state.ifPresent(::render) + playbackModel.state?.let(::render) playbackModel.addListener(this) } @@ -140,20 +133,20 @@ class PlayerControlView @JvmOverloads constructor( binding.ivNext.setOnClickListener { playbackModel.playNext() } - binding.ivPrevious.setOnClickListener(object : MultipleClickListener() { - override fun onSingleClick(view: View?) { - val state = playbackModel.state.getOrNull()?.currentItemState ?: return - if (state.playerState == PlayerState.PAUSED || state.playerState == PlayerState.PLAYING) { - playbackModel.seekToPosition(0L) - } else { - playbackModel.playPrevious() - } - } - - override fun onDoubleClick(view: View?) { - playbackModel.playPrevious() - } - }) + binding.ivPrevious.setOnClickListener( + MultipleClickListener( + onSingleClick = { + playbackModel.state?.currentItemState?.let { state -> + if (state.playerState == PlayerState.PAUSED || state.playerState == PlayerState.PLAYING) { + playbackModel.seekToPosition(0L) + } else { + playbackModel.playPrevious() + } + } + }, + onDoubleClick = { playbackModel.playPrevious() } + ) + ) binding.progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt index d26b02b46cba..811bb23c6147 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -12,102 +12,80 @@ import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import android.widget.LinearLayout +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.viewpager.widget.ViewPager import androidx.viewpager.widget.ViewPager.OnPageChangeListener -import com.nextcloud.client.player.ui.pager.adapter.AbstractFragmentPagerAdapter -import com.nextcloud.client.player.ui.pager.adapter.DefaultFragmentPagerAdapter -import com.nextcloud.client.player.ui.pager.adapter.InfiniteFragmentPagerAdapter -import com.nextcloud.client.player.util.calculateShift +import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.util.rotate import com.owncloud.android.R -class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : +private const val NO_SHIFT = -1 + +class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { + private val viewPager: ViewPager - private lateinit var modeStrategy: ModeStrategy - private lateinit var adapter: AbstractFragmentPagerAdapter - private lateinit var onPageChangeListener: OnPageChangeListener - private var playerPagerListener: PlayerPagerListener? = null - private var currentPosition = -1 - private var shift = -1 - private var restoredShift = -1 + private lateinit var adapter: PlayerPagerAdapter + private var shift = NO_SHIFT + private var restoredShift = NO_SHIFT - init { - inflate(context, R.layout.player_pager, this) - viewPager = findViewById(R.id.viewPager) - } + var onItemSelected: ((PlaybackFile) -> Unit)? = null - fun initialize( - fragmentManager: FragmentManager, - mode: PlayerPagerMode, - fragmentFactory: PlayerPagerFragmentFactory - ) { - modeStrategy = createModeStrategy(mode) - adapter = modeStrategy.createAdapter(fragmentManager, fragmentFactory) - viewPager.setAdapter(adapter) - onPageChangeListener = modeStrategy.createListener() + private val onPageChangeListener = object : OnPageChangeListener { + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit + + override fun onPageSelected(position: Int) { + if (position == 0) { + viewPager.setCurrentItem(adapter.count - 2, false) + return + } + if (position >= adapter.count - 1) { + viewPager.setCurrentItem(1, false) + return + } + onItemSelected?.invoke(adapter.getEntityForPosition(position)) + } + + override fun onPageScrollStateChanged(state: Int) = Unit } - private fun createModeStrategy(mode: PlayerPagerMode): ModeStrategy = when (mode) { - PlayerPagerMode.DEFAULT -> FiniteModeStrategy() - PlayerPagerMode.INFINITE -> InfiniteModeStrategy() + init { + inflate(context, R.layout.player_pager, this) + viewPager = findViewById(R.id.viewPager) } - fun setPlayerPagerListener(playerPagerListener: PlayerPagerListener?) { - this.playerPagerListener = playerPagerListener + fun initialize(fragmentManager: FragmentManager, createFragment: (PlaybackFile) -> Fragment) { + adapter = PlayerPagerAdapter(fragmentManager, createFragment) + viewPager.adapter = adapter } override fun onSaveInstanceState(): Parcelable { - val state = super.onSaveInstanceState() - val infiniteViewPagerState = InfiniteViewPagerState(state) - infiniteViewPagerState.shiftedPosition = shift - return infiniteViewPagerState + val state = PlayerPagerState(super.onSaveInstanceState()) + state.shiftedPosition = shift + return state } override fun onRestoreInstanceState(state: Parcelable?) { - val restoredState: InfiniteViewPagerState = state as InfiniteViewPagerState + val restoredState = state as PlayerPagerState super.onRestoreInstanceState(restoredState.superState) restoredShift = restoredState.shiftedPosition } - fun getItems(): List = adapter.getEntities() - - fun setItems(items: List) { - var items = if (restoredShift != -1) shiftRestoredPosition(items) else items - - val calculatedCurrentPositionWithOffsetIfNeeded = - modeStrategy.getCurrentPosition(adapter.count, currentPosition) - - var currentItem: T? = null - if (calculatedCurrentPositionWithOffsetIfNeeded >= 0 && - currentItemPositionsNotTheSameAfterShuffleMatch(calculatedCurrentPositionWithOffsetIfNeeded) - ) { - currentItem = adapter.getEntities()[calculatedCurrentPositionWithOffsetIfNeeded] - items = calculateShiftAndRotateList(items, calculatedCurrentPositionWithOffsetIfNeeded, currentItem) - } - - adapter.setEntities(items) - if (currentItem != null) { - adapter.setCurrentEntity(if (!items.isEmpty()) currentItem else null) - } + fun getItems(): List = adapter.getEntities() + fun setItems(items: List) { + adapter.setEntities(if (restoredShift != NO_SHIFT) shiftRestoredPosition(items) else items) notifyDataSetChangedWithoutCallingListener() - setCurrentItem(currentItem, false) } - private fun currentItemPositionsNotTheSameAfterShuffleMatch(calculatedCurrentPosition: Int): Boolean = - adapter.getEntities().isEmpty() && - this.currentPosition >= 0 && - calculatedCurrentPosition < adapter.getEntities().size - - private fun calculateShiftAndRotateList( - items: List, - calculatedCurrentPositionWithOffsetForInfinityStrategy: Int, - currentItem: T? - ): List { - shift = items.calculateShift(calculatedCurrentPositionWithOffsetForInfinityStrategy, currentItem) - return items.rotate(shift) + fun setCurrentItem(item: PlaybackFile) { + val position = adapter.getEntityIndex(item) + if (position != -1 && viewPager.currentItem != position) { + viewPager.removeOnPageChangeListener(onPageChangeListener) + viewPager.setCurrentItem(position, true) + viewPager.addOnPageChangeListener(onPageChangeListener) + } } private fun notifyDataSetChangedWithoutCallingListener() { @@ -116,88 +94,13 @@ class PlayerPager @JvmOverloads constructor(context: Context, attrs: Attribut viewPager.addOnPageChangeListener(onPageChangeListener) } - private fun shiftRestoredPosition(items: List): List { + private fun shiftRestoredPosition(items: List): List { shift = restoredShift - restoredShift = -1 + restoredShift = NO_SHIFT return items.rotate(shift) } - fun setCurrentItem(item: T?) { - setCurrentItem(item, true) - } - - private fun setCurrentItem(item: T?, smoothScroll: Boolean) { - currentPosition = item?.let(adapter::getEntityIndex) ?: -1 - if (currentPosition != -1 && viewPager.currentItem != currentPosition) { - viewPager.removeOnPageChangeListener(onPageChangeListener) - viewPager.setCurrentItem(currentPosition, smoothScroll) - viewPager.addOnPageChangeListener(onPageChangeListener) - } - } - - private inner class DefaultOnPageChangeListener : OnPageChangeListener { - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit - - override fun onPageSelected(position: Int) { - playerPagerListener?.onSwitchToItem(adapter.getEntityForPosition(position)) - } - - override fun onPageScrollStateChanged(state: Int) = Unit - } - - private inner class InfinityOnPageChangeListener : OnPageChangeListener { - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit - - override fun onPageSelected(position: Int) { - if (position == 0) { - viewPager.setCurrentItem(adapter.count - 2, false) - return - } - if (position >= adapter.count - 1) { - viewPager.setCurrentItem(1, false) - return - } - playerPagerListener?.onSwitchToItem(adapter.getEntityForPosition(position)) - } - - override fun onPageScrollStateChanged(state: Int) = Unit - } - - private interface ModeStrategy { - fun createAdapter( - fragmentManager: FragmentManager, - fragmentFactory: PlayerPagerFragmentFactory - ): AbstractFragmentPagerAdapter - - fun createListener(): OnPageChangeListener - - fun getCurrentPosition(itemCount: Int, position: Int): Int - } - - private inner class FiniteModeStrategy : ModeStrategy { - override fun createAdapter( - fragmentManager: FragmentManager, - fragmentFactory: PlayerPagerFragmentFactory - ): AbstractFragmentPagerAdapter = DefaultFragmentPagerAdapter(fragmentManager, fragmentFactory) - - override fun createListener(): OnPageChangeListener = DefaultOnPageChangeListener() - - override fun getCurrentPosition(itemCount: Int, position: Int): Int = position - } - - private inner class InfiniteModeStrategy : ModeStrategy { - override fun createAdapter( - fragmentManager: FragmentManager, - fragmentFactory: PlayerPagerFragmentFactory - ): AbstractFragmentPagerAdapter = InfiniteFragmentPagerAdapter(fragmentManager, fragmentFactory) - - override fun createListener(): OnPageChangeListener = InfinityOnPageChangeListener() - - override fun getCurrentPosition(itemCount: Int, position: Int): Int = - if (itemCount > 1) position - 1 else position - } - - class InfiniteViewPagerState : BaseSavedState { + class PlayerPagerState : BaseSavedState { var shiftedPosition: Int = 0 constructor(superState: Parcelable?) : super(superState) @@ -213,11 +116,11 @@ class PlayerPager @JvmOverloads constructor(context: Context, attrs: Attribut companion object { @JvmField - val CREATOR = object : Parcelable.Creator { + val CREATOR = object : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): InfiniteViewPagerState = InfiniteViewPagerState(parcel) + override fun createFromParcel(parcel: Parcel): PlayerPagerState = PlayerPagerState(parcel) - override fun newArray(size: Int): Array = arrayOfNulls(size) + override fun newArray(size: Int): Array = arrayOfNulls(size) } } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt new file mode 100644 index 000000000000..2e016d7deb99 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.pager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import com.nextcloud.client.player.model.file.PlaybackFile + +/** + * Pads the item list with a copy of the last item at the front and a copy of the first item at the end, so that + * [PlayerPager] can wrap around and give the impression of an endless pager. + */ +@Suppress("DEPRECATION") +class PlayerPagerAdapter(fragmentManager: FragmentManager, private val createFragment: (PlaybackFile) -> Fragment) : + FragmentStatePagerAdapter(fragmentManager) { + + private var paddedEntities = mutableListOf() + + fun getEntities(): List = if (isPadded()) removeStubs(paddedEntities) else paddedEntities + + fun setEntities(entities: List) { + paddedEntities = if (entities.size > 1) addStubs(entities) else entities.toMutableList() + notifyDataSetChanged() + } + + fun getEntityIndex(entity: PlaybackFile): Int = if (isPadded()) { + val index = removeStubs(paddedEntities).indexOf(entity) + if (index != -1) index + 1 else index + } else { + paddedEntities.indexOf(entity) + } + + fun getEntityForPosition(position: Int): PlaybackFile = paddedEntities[position] + + override fun getItem(position: Int): Fragment = createFragment(paddedEntities[position]) + + override fun getCount(): Int = paddedEntities.size + + override fun getItemPosition(item: Any): Int = POSITION_NONE + + private fun isPadded(): Boolean = paddedEntities.size > 1 + + private fun addStubs(sources: List): MutableList { + val result = sources.toMutableList() + result.add(0, result[result.size - 1]) + result.add(result[1]) + return result + } + + private fun removeStubs(sources: List): MutableList { + val result = sources.toMutableList() + result.removeAt(0) + result.removeAt(result.size - 1) + return result + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt deleted file mode 100644 index db33e12e2d33..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerFragmentFactory.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.pager - -import androidx.fragment.app.Fragment - -interface PlayerPagerFragmentFactory { - fun create(item: T): Fragment -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt deleted file mode 100644 index c109f6614e32..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerListener.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.pager - -fun interface PlayerPagerListener { - fun onSwitchToItem(item: T) -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt deleted file mode 100644 index ae84941fe569..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerMode.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.pager - -enum class PlayerPagerMode { - DEFAULT, - INFINITE -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt deleted file mode 100644 index 9d657583f4dc..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/AbstractFragmentPagerAdapter.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.pager.adapter - -import android.view.ViewGroup -import androidx.core.util.Pair -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentStatePagerAdapter - -abstract class AbstractFragmentPagerAdapter(fragmentManager: FragmentManager) : - FragmentStatePagerAdapter(fragmentManager) { - protected var currentEntities = mutableListOf() - private var currentEntity: T? = null - private val cachedItems = mutableListOf>() - - abstract fun getEntities(): List - - abstract fun setEntities(entities: List) - - abstract fun getEntityIndex(entity: T): Int - - protected abstract fun getLinkedEntity(position: Int): T - - fun getEntityForPosition(position: Int): T = currentEntities[position] - - fun setCurrentEntity(entity: T?) { - currentEntity = entity - } - - override fun instantiateItem(container: ViewGroup, position: Int): Any { - val fragment = super.instantiateItem(container, position) as Fragment - val linkedEntity = getLinkedEntity(position) - if (!findAndReplace(fragment, linkedEntity)) { - cachedItems.add(Pair(linkedEntity, fragment)) - } - return fragment - } - - private fun findAndReplace(fragment: Fragment, linkedEntity: T): Boolean { - for (pair in cachedItems) { - if (pair.first == linkedEntity) { - val newPair = Pair(pair.first, fragment) - cachedItems.add(newPair) - cachedItems.remove(pair) - return true - } - } - return false - } - - override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { - for (pair in cachedItems) { - if (pair.second == `object`) { - cachedItems.remove(pair) - break - } - } - super.destroyItem(container, position, `object`) - } - - override fun getItemPosition(`object`: Any): Int { - for (pair in cachedItems) { - if (pair.second == `object`) { - return if (currentEntity != null && currentEntity == pair.first) { - super.getItemPosition(`object`) - } else { - POSITION_NONE - } - } - } - return POSITION_NONE - } - - override fun getCount(): Int = currentEntities.size -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt deleted file mode 100644 index d27a9e094bed..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/DefaultFragmentPagerAdapter.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.pager.adapter - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory - -class DefaultFragmentPagerAdapter( - fragmentManager: FragmentManager, - private val fragmentFactory: PlayerPagerFragmentFactory -) : AbstractFragmentPagerAdapter(fragmentManager) { - - override fun getEntities(): List = currentEntities - - override fun setEntities(entities: List) { - this.currentEntities = entities.toMutableList() - notifyDataSetChanged() - } - - override fun getEntityIndex(entity: T): Int = currentEntities.indexOf(entity) - - override fun getLinkedEntity(position: Int): T = currentEntities[position] - - override fun getItem(position: Int): Fragment = fragmentFactory.create(currentEntities[position]) -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt deleted file mode 100644 index a1b3a5105378..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/adapter/InfiniteFragmentPagerAdapter.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 Your Name - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.pager.adapter - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory - -class InfiniteFragmentPagerAdapter( - fragmentManager: FragmentManager, - private val fragmentFactory: PlayerPagerFragmentFactory -) : AbstractFragmentPagerAdapter(fragmentManager) { - - override fun getEntities(): List = if (currentEntities.size > - 1 - ) { - removeStubs(currentEntities) - } else { - currentEntities - } - - override fun setEntities(entities: List) { - this.currentEntities = if (entities.size > 1) { - addStubs(entities) - } else { - entities.toMutableList() - } - notifyDataSetChanged() - } - - override fun getEntityIndex(entity: T): Int = if (currentEntities.size > 1) { - val entities = removeStubs(currentEntities) - val index = entities.indexOf(entity) - if (index != -1) index + 1 else index - } else { - currentEntities.indexOf(entity) - } - - override fun getLinkedEntity(position: Int): T { - val entities = getEntities() - return when (position) { - 0 -> entities[entities.size - 1] - entities.size + 1 -> entities[0] - else -> entities[position - 1] - } - } - - private fun addStubs(sources: List): MutableList { - val result = sources.toMutableList() - result.add(0, result[result.size - 1]) - result.add(result[1]) - return result - } - - private fun removeStubs(sources: List): MutableList { - val result = sources.toMutableList() - result.removeAt(0) - result.removeAt(result.size - 1) - return result - } - - override fun getItem(position: Int): Fragment = fragmentFactory.create(currentEntities[position]) -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index 8f6a08743e20..bf2ac0210158 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -14,7 +14,7 @@ import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.state.PlaybackState @@ -27,7 +27,6 @@ import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding import dagger.android.support.AndroidSupportInjection import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.jvm.optionals.getOrNull class VideoFileFragment : Fragment(), @@ -68,7 +67,7 @@ class VideoFileFragment : override fun onStart() { super.onStart() - render(playerModel.state.getOrNull()) + render(playerModel.state) playerModel.addListener(this) } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt deleted file mode 100644 index 6a7a835e841c..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragmentFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.video - -import androidx.fragment.app.Fragment -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.ui.pager.PlayerPagerFragmentFactory - -class VideoFileFragmentFactory : PlayerPagerFragmentFactory { - - override fun create(item: PlaybackFile): Fragment = VideoFileFragment.createInstance(item) -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt index da7553232c74..9bb0c548fdcd 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt @@ -13,10 +13,11 @@ import android.view.WindowInsets import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.ui.PlayerView import com.owncloud.android.R -import dagger.android.HasAndroidInjector import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -29,14 +30,10 @@ class VideoPlayerView(context: Context) : PlayerView(context) { override val layoutRes get() = R.layout.player_video_view - override val fragmentFactory get() = VideoFileFragmentFactory() + override val createFragment: (PlaybackFile) -> Fragment get() = { VideoFileFragment.createInstance(it) } private var hideControlsTimerJob: Job? = null - override fun inject(context: Context) { - (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) - } - override fun onStart() { super.onStart() showControls() diff --git a/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt b/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt deleted file mode 100644 index a0f3f21c5e28..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/util/ImageView.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.util - -import android.content.res.ColorStateList -import android.widget.ImageView -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import androidx.core.widget.ImageViewCompat - -fun ImageView.setTint(@ColorRes colorRes: Int) { - val color = ContextCompat.getColor(context, colorRes) - ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(color)) -} diff --git a/app/src/main/java/com/nextcloud/client/player/util/List.kt b/app/src/main/java/com/nextcloud/client/player/util/List.kt index 502d36d8774b..05c238c78a1d 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/List.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/List.kt @@ -9,15 +9,6 @@ package com.nextcloud.client.player.util import java.util.Collections -fun List.calculateShift(targetIndex: Int, item: T?): Int { - val currentIndex = indexOf(item) - return if (currentIndex >= 0 && currentIndex != targetIndex) { - (size - currentIndex + targetIndex) % size - } else { - 0 - } -} - fun List.rotate(shift: Int): List { val copy = ArrayList(this) Collections.rotate(copy, shift) diff --git a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt index a9e420cac91b..9681bb72d0b4 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt @@ -5,39 +5,31 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -@file:JvmName("ScreenUtils") - package com.nextcloud.client.player.util import android.content.Context import android.os.Build import android.util.DisplayMetrics import android.view.WindowManager -import android.view.WindowMetrics -import androidx.annotation.RequiresApi fun Context.getDisplayWidth(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - getWindowMetrics().bounds.width() + windowManager.currentWindowMetrics.bounds.width() } else { getDisplayMetrics().widthPixels } fun Context.getDisplayHeight(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - getWindowMetrics().bounds.height() + windowManager.currentWindowMetrics.bounds.height() } else { getDisplayMetrics().heightPixels } -@RequiresApi(Build.VERSION_CODES.R) -fun Context.getWindowMetrics(): WindowMetrics { - val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager - return windowManager.currentWindowMetrics -} +private val Context.windowManager: WindowManager + get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager @Suppress("DEPRECATION") -fun Context.getDisplayMetrics(): DisplayMetrics { +private fun Context.getDisplayMetrics(): DisplayMetrics { val displayMetrics = DisplayMetrics() - val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager windowManager.defaultDisplay.getRealMetrics(displayMetrics) return displayMetrics } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index fb6d4f8366c7..805b645a9095 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -57,7 +57,7 @@ import com.nextcloud.client.files.DeepLinkConstants; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.onboarding.FirstRunActivity; -import com.nextcloud.client.player.model.PlaybackModel; +import com.nextcloud.client.player.media3.PlaybackModel; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.common.NextcloudClient; import com.nextcloud.ui.ChooseAccountDialogFragment; diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt index 5fcb62b54640..4a9063545718 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt @@ -19,7 +19,7 @@ import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.player.model.PlaybackModel +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.utils.extensions.getParcelableArgument import com.owncloud.android.R import com.owncloud.android.databinding.AccountRemovalDialogBinding From 86c0fac7c1db8011c5a54daf0b8c57b7c2f22c0c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 17:11:17 +0200 Subject: [PATCH 26/69] remove unused classes Signed-off-by: alperozturk96 --- .../com/nextcloud/client/di/AppComponent.kt | 6 - .../nextcloud/client/di/ComponentsModule.java | 12 +- .../com/nextcloud/client/media/AudioFocus.kt | 31 - .../client/media/AudioFocusManager.kt | 58 -- .../client/media/BackgroundPlayerService.kt | 298 -------- .../com/nextcloud/client/media/ErrorFormat.kt | 113 --- .../client/media/ExoplayerListener.kt | 64 -- .../client/media/NextcloudExoPlayer.kt | 48 -- .../nextcloud/client/media/PlaylistItem.kt | 13 - .../client/player/ui/PlayerLauncher.kt | 50 +- .../player/ui/video/VideoFileFragment.kt | 23 +- .../client/player/util/SurfaceView.kt | 32 + .../android/media/MediaControlView.kt | 355 ---------- .../ui/activity/FileDisplayActivity.kt | 62 +- .../ui/fragment/OCFileListFragment.java | 2 +- .../ui/preview/PreviewImageFragment.kt | 12 +- .../ui/preview/PreviewMediaFragment.kt | 661 ------------------ .../ui/preview/PreviewMediaPagerAdapter.kt | 22 +- .../ui/preview/PreviewPlaybackFragment.kt | 157 +++++ .../preview/PreviewVideoFullscreenDialog.kt | 202 ------ app/src/main/res/drawable/ic_fast_forward.xml | 16 - app/src/main/res/drawable/ic_fast_rewind.xml | 16 - app/src/main/res/drawable/ic_pause.xml | 15 - app/src/main/res/drawable/ic_skip_next.xml | 15 - .../main/res/drawable/ic_skip_previous.xml | 15 - .../main/res/layout/dialog_preview_video.xml | 22 - .../res/layout/fragment_preview_media.xml | 60 -- app/src/main/res/layout/media_control.xml | 107 --- .../res/layout/preview_playback_fragment.xml | 41 ++ app/src/main/res/layout/view_media_player.xml | 12 - .../client/media/AudioFocusManagerTest.kt | 63 -- .../nextcloud/client/media/AudioFocusTest.kt | 40 -- 32 files changed, 303 insertions(+), 2340 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/media/AudioFocus.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt delete mode 100644 app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt delete mode 100644 app/src/main/java/com/owncloud/android/media/MediaControlView.kt delete mode 100644 app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt delete mode 100644 app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt delete mode 100644 app/src/main/res/drawable/ic_fast_forward.xml delete mode 100644 app/src/main/res/drawable/ic_fast_rewind.xml delete mode 100644 app/src/main/res/drawable/ic_pause.xml delete mode 100644 app/src/main/res/drawable/ic_skip_next.xml delete mode 100644 app/src/main/res/drawable/ic_skip_previous.xml delete mode 100644 app/src/main/res/layout/dialog_preview_video.xml delete mode 100644 app/src/main/res/layout/fragment_preview_media.xml delete mode 100644 app/src/main/res/layout/media_control.xml create mode 100644 app/src/main/res/layout/preview_playback_fragment.xml delete mode 100644 app/src/main/res/layout/view_media_player.xml delete mode 100644 app/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt delete mode 100644 app/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt index 06aaef9c3776..de3a4edd9688 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt @@ -18,13 +18,11 @@ import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver import com.nextcloud.client.jobs.upload.FileUploadHelper -import com.nextcloud.client.media.BackgroundPlayerService import com.nextcloud.client.network.NetworkModule import com.nextcloud.client.onboarding.OnboardingModule import com.nextcloud.client.player.PlayerModule import com.nextcloud.client.preferences.PreferencesModule import com.owncloud.android.MainApp -import com.owncloud.android.media.MediaControlView import com.owncloud.android.ui.ThemeableSwitchPreference import com.owncloud.android.ui.whatsnew.ProgressIndicator import dagger.BindsInstance @@ -56,10 +54,6 @@ import javax.inject.Singleton interface AppComponent { fun inject(app: MainApp) - fun inject(mediaControlView: MediaControlView) - - fun inject(backgroundPlayerService: BackgroundPlayerService) - fun inject(switchPreference: ThemeableSwitchPreference) fun inject(fileUploadHelper: FileUploadHelper) diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 8f0a2a3315d6..f1a2ac752b30 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -18,7 +18,6 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.logger.ui.LogsActivity; import com.nextcloud.client.logger.ui.LogsViewModel; -import com.nextcloud.client.media.BackgroundPlayerService; import com.nextcloud.client.migrations.Migrations; import com.nextcloud.client.onboarding.FirstRunActivity; import com.nextcloud.client.onboarding.WhatsNewActivity; @@ -128,7 +127,7 @@ import com.owncloud.android.ui.preview.PreviewBitmapActivity; import com.owncloud.android.ui.preview.PreviewImageActivity; import com.owncloud.android.ui.preview.PreviewImageFragment; -import com.owncloud.android.ui.preview.PreviewMediaFragment; +import com.owncloud.android.ui.preview.PreviewPlaybackFragment; import com.owncloud.android.ui.preview.PreviewTextFileFragment; import com.owncloud.android.ui.preview.PreviewTextFragment; import com.owncloud.android.ui.preview.PreviewTextStringFragment; @@ -292,9 +291,6 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract BackupListFragment chooseContactListFragment(); - @ContributesAndroidInjector - abstract PreviewMediaFragment previewMediaFragment(); - @ContributesAndroidInjector abstract PreviewTextFragment previewTextFragment(); @@ -304,6 +300,9 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract SetOnlineStatusBottomSheet setOnlineStatusBottomSheet(); + @ContributesAndroidInjector + abstract PreviewPlaybackFragment previewPlaybackFragment(); + @ContributesAndroidInjector abstract PreviewTextFileFragment previewTextFileFragment(); @@ -497,9 +496,6 @@ abstract class ComponentsModule { abstract InternalTwoWaySyncActivity internalTwoWaySyncActivity(); @OptIn(markerClass = UnstableApi.class) - @ContributesAndroidInjector - abstract BackgroundPlayerService backgroundPlayerService(); - @ContributesAndroidInjector abstract TermsOfServiceDialog termsOfServiceDialog(); diff --git a/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt b/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt deleted file mode 100644 index c60702bb9b47..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioManager - -/** - * Simplified audio focus values, relevant to application's media player experience. - */ -internal enum class AudioFocus { - - LOST, - DUCK, - FOCUS; - - companion object { - fun fromPlatformFocus(audioFocus: Int): AudioFocus? = when (audioFocus) { - AudioManager.AUDIOFOCUS_GAIN -> FOCUS - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> FOCUS - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> FOCUS - AudioManager.AUDIOFOCUS_LOSS -> LOST - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> LOST - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> DUCK - else -> null - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt b/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt deleted file mode 100644 index 3cd1e1da7166..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioFocusRequest -import android.media.AudioManager - -/** - * Wrapper around audio manager exposing simplified audio focus API and - * hiding platform API level differences. - * - * @param audioManger Platform audio manager - * @param onFocusChange Called when audio focus changes, including acquired and released focus states - */ -internal class AudioFocusManager( - private val audioManger: AudioManager, - private val onFocusChange: (AudioFocus) -> Unit, - requestBuilder: AudioFocusRequest.Builder = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) -) { - private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> - val focus = when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> AudioFocus.FOCUS - - AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> AudioFocus.LOST - - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> AudioFocus.DUCK - - else -> null - } - focus?.let { onFocusChange(it) } - } - - private val focusRequest = requestBuilder - .setWillPauseWhenDucked(true) - .setOnAudioFocusChangeListener(focusListener) - .build() - - fun requestFocus() { - val requestResult = audioManger.requestAudioFocus(focusRequest) - if (requestResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) - } else { - focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) - } - } - - fun releaseFocus() { - audioManger.abandonAudioFocusRequest(focusRequest) - focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt b/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt deleted file mode 100644 index 1d3517400603..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-FileCopyrightText: 2024 Parneet Singh - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.media - -import android.app.NotificationManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.Bundle -import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat -import androidx.media3.common.Player -import androidx.media3.common.Player.COMMAND_PLAY_PAUSE -import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT -import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS -import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSession.ConnectionResult -import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder -import androidx.media3.session.MediaSessionService -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.di.Injectable -import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.common.NextcloudClient -import com.nextcloud.utils.extensions.registerBroadcastReceiver -import com.owncloud.android.MainApp -import com.owncloud.android.R -import com.owncloud.android.datamodel.ReceiverFlag -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.notifications.NotificationUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@OptIn(UnstableApi::class) -class BackgroundPlayerService : - MediaSessionService(), - Injectable { - - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - private val seekBackSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_BACK, Bundle.EMPTY) - private val seekForwardSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_FORWARD, Bundle.EMPTY) - - private lateinit var seekForward: CommandButton - private lateinit var seekBackward: CommandButton - - @Inject - lateinit var clientFactory: ClientFactory - - @Inject - lateinit var userAccountManager: UserAccountManager - - private lateinit var exoPlayer: ExoPlayer - private var mediaSession: MediaSession? = null - private var isPlayerReady = false - - private val stopReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - RELEASE_MEDIA_SESSION_BROADCAST_ACTION -> release() - - STOP_MEDIA_SESSION_BROADCAST_ACTION -> { - if (isPlayerReady) { - exoPlayer.stop() - } else { - stopSelf() - } - } - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val notification = NotificationCompat.Builder(this, NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) - .setSmallIcon(R.drawable.logo) - .setContentTitle(getString(R.string.media_player_playing)) - .setSilent(true) - .build() - - ServiceCompat.startForeground( - this, - DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID, - notification, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - } else { - 0 - } - ) - - return super.onStartCommand(intent, flags, startId) - } - - @Suppress("DEPRECATION") - override fun onCreate() { - super.onCreate() - - MainApp.getAppComponent().inject(this) - - if (userAccountManager.allUsers.isEmpty()) { - return - } - - seekForward = CommandButton.Builder() - .setDisplayName(getString(R.string.media_player_seek_forward)) - .setIconResId(R.drawable.ic_skip_next) - .setSessionCommand(seekForwardSessionCommand) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 2) }) - .build() - - seekBackward = CommandButton.Builder() - .setDisplayName(getString(R.string.media_player_seek_backward)) - .setIconResId(R.drawable.ic_skip_previous) - .setSessionCommand(seekBackSessionCommand) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 0) }) - .build() - - exoPlayer = ExoPlayer.Builder(this).build() - mediaSession = buildMediaSession(exoPlayer) - - setMediaNotificationProvider(buildNotificationProvider()) - - registerBroadcastReceiver( - stopReceiver, - IntentFilter().apply { - addAction(RELEASE_MEDIA_SESSION_BROADCAST_ACTION) - addAction(STOP_MEDIA_SESSION_BROADCAST_ACTION) - }, - ReceiverFlag.NotExported - ) - - initExoPlayer() - } - - @Suppress("TooGenericExceptionCaught") - private fun initExoPlayer() { - serviceScope.launch { - try { - val nextcloudClient: NextcloudClient = withContext(Dispatchers.IO) { - clientFactory.createNextcloudClient(userAccountManager.user) - } - - val realPlayer = createNextcloudExoplayer(this@BackgroundPlayerService, nextcloudClient) - exoPlayer.release() - exoPlayer = realPlayer - isPlayerReady = true - - // Update the session to use the real player - mediaSession?.player = realPlayer - } catch (e: Exception) { - Log_OC.e(TAG, "Failed to initialise Nextcloud ExoPlayer: ${e.message}") - stopSelf() - } - } - } - - private fun buildMediaSession(player: ExoPlayer): MediaSession = MediaSession.Builder(applicationContext, player) - .setId(BACKGROUND_MEDIA_SESSION_ID) - .setCustomLayout(listOf(seekBackward, seekForward)) - .setCallback(object : MediaSession.Callback { - override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): ConnectionResult = - AcceptedResultBuilder(mediaSession ?: session) - .setAvailablePlayerCommands( - ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() - .remove(COMMAND_SEEK_TO_NEXT) - .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) - .remove(COMMAND_SEEK_TO_PREVIOUS) - .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) - .build() - ) - .setAvailableSessionCommands( - ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() - .addSessionCommands( - listOf(seekBackSessionCommand, seekForwardSessionCommand) - ).build() - ) - .build() - - override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { - session.setCustomLayout(listOf(seekBackward, seekForward)) - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = when (customCommand.customAction) { - SESSION_COMMAND_ACTION_SEEK_FORWARD -> { - session.player.seekForward() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - SESSION_COMMAND_ACTION_SEEK_BACK -> { - session.player.seekBack() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - else -> super.onCustomCommand(session, controller, customCommand, args) - } - }) - .build() - - private fun buildNotificationProvider() = object : DefaultMediaNotificationProvider(this) { - @Suppress("DEPRECATION") - override fun getMediaButtons( - session: MediaSession, - playerCommands: Player.Commands, - customLayout: ImmutableList, - showPauseButton: Boolean - ): ImmutableList { - val isPlaying = mediaSession?.player?.isPlaying == true - val playPauseButton = CommandButton.Builder() - .setDisplayName( - if (isPlaying) { - getString(R.string.media_player_pause) - } else { - getString(R.string.media_player_play) - } - ) - .setIconResId(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow) - .setPlayerCommand(COMMAND_PLAY_PAUSE) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 1) }) - .build() - - return ImmutableList.of(seekBackward, playPauseButton, seekForward) - } - } - - override fun onTaskRemoved(rootIntent: Intent?) { - release() - } - - override fun onDestroy() { - unregisterReceiver(stopReceiver) - serviceScope.cancel() - mediaSession?.run { - player.release() - release() - mediaSession = null - } - super.onDestroy() - } - - private fun release() { - val player = mediaSession?.player - if (player?.playWhenReady == true) { - player.pause() - } - val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - nm.cancel(DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession - - companion object { - private val TAG = BackgroundPlayerService::class.java.simpleName - - private const val SESSION_COMMAND_ACTION_SEEK_BACK = "SESSION_COMMAND_ACTION_SEEK_BACK" - private const val SESSION_COMMAND_ACTION_SEEK_FORWARD = "SESSION_COMMAND_ACTION_SEEK_FORWARD" - private const val BACKGROUND_MEDIA_SESSION_ID = - "com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID" - - const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION = - "com.nextcloud.client.media.RELEASE_MEDIA_SESSION" - const val STOP_MEDIA_SESSION_BROADCAST_ACTION = - "com.nextcloud.client.media.STOP_MEDIA_SESSION" - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt b/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt deleted file mode 100644 index 8b34266453a7..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import android.media.MediaPlayer -import androidx.media3.common.PlaybackException -import com.owncloud.android.R - -/** - * This code has been moved from legacy media player service. - */ -@Deprecated("This legacy helper should be refactored") -@Suppress("ComplexMethod") // it's legacy code -object ErrorFormat { - - /** Error code for specific messages - see regular error codes at [MediaPlayer] */ - const val OC_MEDIA_ERROR = 0 - - @JvmStatic - fun toString(context: Context?, what: Int, extra: Int): String { - val messageId: Int - - if (what == OC_MEDIA_ERROR) { - messageId = extra - } else if (extra == MediaPlayer.MEDIA_ERROR_UNSUPPORTED) { - /* Added in API level 17 - Bitstream is conforming to the related coding standard or file spec, - but the media framework does not support the feature. - Constant Value: -1010 (0xfffffc0e) - */ - messageId = R.string.media_err_unsupported - } else if (extra == MediaPlayer.MEDIA_ERROR_IO) { - /* Added in API level 17 - File or network related operation errors. - Constant Value: -1004 (0xfffffc14) - */ - messageId = R.string.media_err_io - } else if (extra == MediaPlayer.MEDIA_ERROR_MALFORMED) { - /* Added in API level 17 - Bitstream is not conforming to the related coding standard or file spec. - Constant Value: -1007 (0xfffffc11) - */ - messageId = R.string.media_err_malformed - } else if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) { - /* Added in API level 17 - Some operation takes too long to complete, usually more than 3-5 seconds. - Constant Value: -110 (0xffffff92) - */ - messageId = R.string.media_err_timeout - } else if (what == MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK) { - /* Added in API level 3 - The video is streamed and its container is not valid for progressive playback i.e the video's index - (e.g moov atom) is not at the start of the file. - Constant Value: 200 (0x000000c8) - */ - messageId = R.string.media_err_invalid_progressive_playback - } else { - /* MediaPlayer.MEDIA_ERROR_UNKNOWN - Added in API level 1 - Unspecified media player error. - Constant Value: 1 (0x00000001) - */ - /* MediaPlayer.MEDIA_ERROR_SERVER_DIED) - Added in API level 1 - Media server died. In this case, the application must release the MediaPlayer - object and instantiate a new one. - Constant Value: 100 (0x00000064) - */ - messageId = R.string.media_err_unknown - } - return context?.getString(messageId) ?: "Media error" - } - - fun toString(context: Context, exception: PlaybackException): String { - val messageId = when (exception.errorCode) { - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { - R.string.media_err_unsupported - } - - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, - PlaybackException.ERROR_CODE_IO_NO_PERMISSION, - PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE -> { - R.string.media_err_io - } - - PlaybackException.ERROR_CODE_TIMEOUT -> { - R.string.media_err_timeout - } - - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { - R.string.media_err_malformed - } - - else -> { - R.string.media_err_invalid_progressive_playback - } - } - return context.getString(messageId) - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt b/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt deleted file mode 100644 index 3b0dd56ef341..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import android.content.DialogInterface -import android.view.View -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.owncloud.android.R -import com.owncloud.android.lib.common.utils.Log_OC - -class ExoplayerListener( - private val context: Context, - private val playerView: View, - private val exoPlayer: ExoPlayer, - private val onCompleted: () -> Unit = { } -) : Player.Listener { - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - if (playbackState == Player.STATE_ENDED) { - onCompletion() - onCompleted() - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - super.onIsPlayingChanged(isPlaying) - Log_OC.d(TAG, "Exoplayer keep screen on: $isPlaying") - playerView.keepScreenOn = isPlaying - } - - private fun onCompletion() { - exoPlayer.let { - it.seekToDefaultPosition() - it.pause() - } - } - - override fun onPlayerError(error: PlaybackException) { - super.onPlayerError(error) - Log_OC.e(TAG, "Exoplayer error", error) - val message = ErrorFormat.toString(context, error) - MaterialAlertDialogBuilder(context) - .setMessage(message) - .setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int -> - onCompletion() - } - .setCancelable(false) - .show() - } - - companion object { - private const val TAG = "ExoplayerListener" - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt b/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt deleted file mode 100644 index 283f4e56ba04..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import androidx.annotation.OptIn -import androidx.media3.common.AudioAttributes -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import com.nextcloud.common.NextcloudClient -import com.owncloud.android.MainApp - -object NextcloudExoPlayer { - private const val FIVE_SECONDS_IN_MILLIS = 5000L - - /** - * Creates an [ExoPlayer] that uses [NextcloudClient] for HTTP connections, thus respecting redirections, - * IP versions and certificates. - * - */ - @OptIn(UnstableApi::class) - @JvmStatic - fun createNextcloudExoplayer(context: Context, nextcloudClient: NextcloudClient): ExoPlayer { - val okHttpDataSourceFactory = OkHttpDataSource.Factory(nextcloudClient.client) - okHttpDataSourceFactory.setUserAgent(MainApp.getUserAgent()) - val mediaSourceFactory = DefaultMediaSourceFactory( - DefaultDataSource.Factory( - context, - okHttpDataSourceFactory - ) - ) - return ExoPlayer - .Builder(context) - .setMediaSourceFactory(mediaSourceFactory) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setSeekForwardIncrementMs(FIVE_SECONDS_IN_MILLIS) - .build() - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt b/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt deleted file mode 100644 index 2415145733ad..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2021 Tobias Kaminsky - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import com.nextcloud.client.account.User -import com.owncloud.android.datamodel.OCFile - -data class PlaylistItem(val file: OCFile, val startPositionMs: Long, val autoPlay: Boolean, val user: User) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt index 56339aae0314..d9912f12c756 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt @@ -8,10 +8,11 @@ package com.nextcloud.client.player.ui import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.nextcloud.client.logger.Logger -import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.file.PlaybackFilesComparator @@ -32,26 +33,47 @@ class PlayerLauncher @Inject constructor( ) { private var currentLaunchJob: Job? = null + /** + * Starts playback and opens [PlayerActivity] on top of [activity]. + */ fun launch(activity: AppCompatActivity, file: OCFile, searchType: SearchType?) { - currentLaunchJob?.cancel() - currentLaunchJob = activity.lifecycleScope.launch { - runCatching { - val fileType = PlaybackFileType.ofMimeType(file.mimeType) - playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, searchType) - - val currentPlaybackFile = file.toPlaybackFile() + run(activity) { + val fileType = prepareQueue(file, searchType) + playbackModel.play() + activity.startActivity(PlayerActivity.createIntent(activity, fileType)) + } + } - playbackModel.start() - playbackModel.setFiles(PlaybackFiles(listOf(currentPlaybackFile), PlaybackFilesComparator.NONE)) - playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, searchType)) + /** + * Loads [file] and the media of its collection into the player without opening [PlayerActivity], so that a host + * screen can render the playback itself. + */ + fun prepare(owner: LifecycleOwner, file: OCFile, searchType: SearchType?, autoplay: Boolean = false) { + run(owner) { + prepareQueue(file, searchType) + if (autoplay) { playbackModel.play() + } + } + } - val intent = PlayerActivity.createIntent(activity, fileType) - activity.startActivity(intent) - }.onFailure { + private fun run(owner: LifecycleOwner, block: suspend () -> Unit) { + currentLaunchJob?.cancel() + currentLaunchJob = owner.lifecycleScope.launch { + runCatching { block() }.onFailure { if (it is CancellationException) throw it logger.e(PlayerLauncher::class.java.simpleName, "Error launching player", it) } } } + + private suspend fun prepareQueue(file: OCFile, searchType: SearchType?): PlaybackFileType { + val fileType = PlaybackFileType.ofMimeType(file.mimeType) + playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, searchType) + + playbackModel.start() + playbackModel.setFiles(PlaybackFiles(listOf(file.toPlaybackFile()), PlaybackFilesComparator.NONE)) + playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, searchType)) + return fileType + } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index bf2ac0210158..0cf4919e9e7f 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -19,8 +19,7 @@ import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.VideoSize -import com.nextcloud.client.player.util.getDisplayHeight -import com.nextcloud.client.player.util.getDisplayWidth +import com.nextcloud.client.player.util.applyVideoSize import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding @@ -108,25 +107,7 @@ class VideoFileFragment : if (videoSize != null && previousVideoSize != videoSize) { previousVideoSize = videoSize - setVideoSize(videoSize.width, videoSize.height) + binding.surfaceView.applyVideoSize(videoSize) } } - - private fun setVideoSize(videoWidth: Int, videoHeight: Int) { - val screenWidth = requireContext().getDisplayWidth() - val screenHeight = requireContext().getDisplayHeight() - val screenProportion = screenWidth.toFloat() / screenHeight.toFloat() - val videoProportion = videoWidth.toFloat() / videoHeight.toFloat() - - val layoutParams = binding.surfaceView.layoutParams - if (screenProportion < videoProportion) { - layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - layoutParams.height = (screenWidth.toFloat() / videoProportion).toInt() - } else { - layoutParams.width = (videoProportion * screenHeight.toFloat()).toInt() - layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - } - - binding.surfaceView.layoutParams = layoutParams - } } diff --git a/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt b/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt new file mode 100644 index 000000000000..dcbfd3555790 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.util + +import android.view.SurfaceView +import android.view.ViewGroup +import com.nextcloud.client.player.model.state.VideoSize + +/** + * Letterboxes the surface inside its container so the video keeps its aspect ratio. + */ +fun SurfaceView.applyVideoSize(videoSize: VideoSize) { + val screenWidth = context.getDisplayWidth() + val screenHeight = context.getDisplayHeight() + val screenProportion = screenWidth.toFloat() / screenHeight.toFloat() + val videoProportion = videoSize.width.toFloat() / videoSize.height.toFloat() + + layoutParams = layoutParams.apply { + if (screenProportion < videoProportion) { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = (screenWidth.toFloat() / videoProportion).toInt() + } else { + width = (videoProportion * screenHeight.toFloat()).toInt() + height = ViewGroup.LayoutParams.MATCH_PARENT + } + } +} diff --git a/app/src/main/java/com/owncloud/android/media/MediaControlView.kt b/app/src/main/java/com/owncloud/android/media/MediaControlView.kt deleted file mode 100644 index 217c1c153501..000000000000 --- a/app/src/main/java/com/owncloud/android/media/MediaControlView.kt +++ /dev/null @@ -1,355 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Alper Ozturk - * SPDX-FileCopyrightText: 2022 Álvaro Brey Vilas - * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2018 Andy Scherzinger - * SPDX-FileCopyrightText: 2015 ownCloud Inc. - * SPDX-FileCopyrightText: 2013 David A. Velasco - * SPDX-License-Identifier: GPL-2.0-only AND AGPL-3.0-or-later - */ -package com.owncloud.android.media - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.util.AttributeSet -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.View -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo -import android.widget.LinearLayout -import android.widget.SeekBar -import android.widget.SeekBar.OnSeekBarChangeListener -import androidx.core.content.ContextCompat -import androidx.media3.common.Player -import com.owncloud.android.MainApp -import com.owncloud.android.R -import com.owncloud.android.databinding.MediaControlBinding -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.utils.theme.ViewThemeUtils -import java.util.Formatter -import java.util.Locale -import javax.inject.Inject - -/** - * View containing controls for a MediaPlayer. - * - * - * Holds buttons "play / pause", "rewind", "fast forward" and a progress slider. - * - * - * It synchronizes itself with the state of the MediaPlayer. - */ -class MediaControlView(context: Context, attrs: AttributeSet?) : - LinearLayout(context, attrs), - View.OnClickListener, - OnSeekBarChangeListener { - - private var playerControl: Player? = null - private var binding: MediaControlBinding - private var isDragging = false - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - public override fun onFinishInflate() { - super.onFinishInflate() - } - - @Suppress("MagicNumber") - fun setMediaPlayer(player: Player?) { - playerControl = player - handler.sendEmptyMessage(SHOW_PROGRESS) - - handler.postDelayed({ - updatePausePlay() - setProgress() - }, 100) - } - - @Suppress("MagicNumber") - private fun initControllerView() { - binding.playBtn.requestFocus() - - binding.playBtn.setOnClickListener(this) - binding.forwardBtn.setOnClickListener(this) - binding.rewindBtn.setOnClickListener(this) - - binding.progressBar.run { - viewThemeUtils.platform.themeHorizontalSeekBar(this) - setMax(1000) - } - - binding.progressBar.setOnSeekBarChangeListener(this) - - viewThemeUtils.material.run { - colorMaterialButtonPrimaryTonal(binding.rewindBtn) - colorMaterialButtonPrimaryTonal(binding.playBtn) - colorMaterialButtonPrimaryTonal(binding.forwardBtn) - } - } - - /** - * Disable pause or seek buttons if the stream cannot be paused or seeked. - * This requires the control interface to be a MediaPlayerControlExt - */ - private fun disableUnsupportedButtons() { - try { - if (playerControl?.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)?.not() == true) { - binding.playBtn.isEnabled = false - } - - if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK)?.not() == true) { - binding.rewindBtn.isEnabled = false - } - if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD)?.not() == true) { - binding.forwardBtn.isEnabled = false - } - } catch (ex: IncompatibleClassChangeError) { - // We were given an old version of the interface, that doesn't have - // the canPause/canSeekXYZ methods. This is OK, it just means we - // assume the media can be paused and seeked, and so we don't disable - // the buttons. - Log_OC.i(TAG, "Old media interface detected") - } - } - - @Suppress("MagicNumber") - private val handler: Handler = object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - if (msg.what == SHOW_PROGRESS) { - updatePausePlay() - val pos = setProgress() - - if (!isDragging) { - sendMessageDelayed(obtainMessage(SHOW_PROGRESS), (1000 - pos % 1000)) - } - } - } - } - - init { - MainApp.getAppComponent().inject(this) - - val inflate = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - binding = MediaControlBinding.inflate(inflate, this, true) - initControllerView() - isFocusable = true - setFocusableInTouchMode(true) - setDescendantFocusability(FOCUS_AFTER_DESCENDANTS) - requestFocus() - } - - @Suppress("MagicNumber") - private fun formatTime(timeMs: Long): String { - val totalSeconds = timeMs / 1000 - val seconds = totalSeconds % 60 - val minutes = totalSeconds / 60 % 60 - val hours = totalSeconds / 3600 - val mFormatBuilder = StringBuilder() - val mFormatter = Formatter(mFormatBuilder, Locale.getDefault()) - return if (hours > 0) { - mFormatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() - } else { - mFormatter.format("%02d:%02d", minutes, seconds).toString() - } - } - - @Suppress("MagicNumber") - private fun setProgress(): Long { - var position = 0L - if (playerControl == null || isDragging) { - position = 0 - } - - playerControl?.let { playerControl -> - position = playerControl.currentPosition - val duration = playerControl.duration - if (duration > 0) { - // use long to avoid overflow - val pos = 1000L * position / duration - binding.progressBar.progress = pos.toInt() - } - val percent = playerControl.bufferedPercentage - binding.progressBar.setSecondaryProgress(percent * 10) - val endTime = if (duration > 0) formatTime(duration) else "--:--" - binding.totalTimeText.text = endTime - binding.currentTimeText.text = formatTime(position) - } - - return position - } - - @Suppress("ReturnCount") - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val keyCode = event.keyCode - val uniqueDown = (event.repeatCount == 0 && event.action == KeyEvent.ACTION_DOWN) - - when (keyCode) { - KeyEvent.KEYCODE_HEADSETHOOK, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_SPACE -> { - if (uniqueDown) { - doPauseResume() - // show(sDefaultTimeout); - binding.playBtn.requestFocus() - } - return true - } - - KeyEvent.KEYCODE_MEDIA_PLAY -> { - if (uniqueDown && playerControl?.playWhenReady == false) { - playerControl?.play() - updatePausePlay() - } - return true - } - - KeyEvent.KEYCODE_MEDIA_STOP, - KeyEvent.KEYCODE_MEDIA_PAUSE - -> { - if (uniqueDown && playerControl?.playWhenReady == true) { - playerControl?.pause() - updatePausePlay() - } - return true - } - - else -> return super.dispatchKeyEvent(event) - } - } - - fun updatePausePlay() { - binding.playBtn.icon = ContextCompat.getDrawable( - context, - // use isPlaying instead of playWhenReady - // it represents only the play/pause state - // which is needed to show play/pause icons - if (playerControl?.isPlaying == true) { - R.drawable.ic_pause - } else { - R.drawable.ic_play - } - ) - binding.forwardBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD) == true) { - VISIBLE - } else { - INVISIBLE - } - binding.rewindBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK) == true) { - VISIBLE - } else { - INVISIBLE - } - } - - private fun doPauseResume() { - playerControl?.run { - if (playWhenReady) { - pause() - } else { - play() - } - } - updatePausePlay() - } - - override fun setEnabled(enabled: Boolean) { - binding.playBtn.setEnabled(enabled) - binding.forwardBtn.setEnabled(enabled) - binding.rewindBtn.setEnabled(enabled) - binding.progressBar.setEnabled(enabled) - - disableUnsupportedButtons() - - super.setEnabled(enabled) - } - - @Suppress("MagicNumber") - override fun onClick(v: View) { - playerControl?.let { playerControl -> - val playing = playerControl.playWhenReady - val id = v.id - - when (id) { - R.id.playBtn -> { - doPauseResume() - } - - R.id.rewindBtn -> { - playerControl.seekBack() - if (!playing) { - playerControl.pause() // necessary in some 2.3.x devices - } - setProgress() - } - - R.id.forwardBtn -> { - playerControl.seekForward() - if (!playing) { - playerControl.pause() // necessary in some 2.3.x devices - } - - setProgress() - } - - else -> { - } - } - } - } - - @Suppress("MagicNumber") - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (!fromUser) { - // We're not interested in programmatically generated changes to - // the progress bar's position. - return - } - - playerControl?.let { playerControl -> - val duration = playerControl.duration - val newPosition = duration * progress / 1000L - playerControl.seekTo(newPosition) - binding.currentTimeText.text = formatTime(newPosition) - } - } - - /** - * Called in devices with touchpad when the user starts to adjust the position of the seekbar's thumb. - * - * Will be followed by several onProgressChanged notifications. - */ - override fun onStartTrackingTouch(seekBar: SeekBar) { - isDragging = true // monitors the duration of dragging - handler.removeMessages(SHOW_PROGRESS) // grants no more updates with media player progress while dragging - } - - /** - * Called in devices with touchpad when the user finishes the adjusting of the seekbar. - */ - override fun onStopTrackingTouch(seekBar: SeekBar) { - isDragging = false - setProgress() - updatePausePlay() - handler.sendEmptyMessage(SHOW_PROGRESS) // grants future updates with media player progress - } - - override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) { - super.onInitializeAccessibilityEvent(event) - event.setClassName(MediaControlView::class.java.getName()) - } - - override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { - super.onInitializeAccessibilityNodeInfo(info) - info.setClassName(MediaControlView::class.java.getName()) - } - - companion object { - private val TAG = MediaControlView::class.java.getSimpleName() - private const val SHOW_PROGRESS = 1 - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 797ba3c088ed..8e5384b4a773 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -150,8 +150,6 @@ import com.owncloud.android.ui.interfaces.TransactionInterface import com.owncloud.android.ui.navigation.NavigatorScreen import com.owncloud.android.ui.preview.PreviewImageActivity import com.owncloud.android.ui.preview.PreviewImageFragment -import com.owncloud.android.ui.preview.PreviewMediaFragment -import com.owncloud.android.ui.preview.PreviewMediaFragment.Companion.newInstance import com.owncloud.android.ui.preview.PreviewTextFileFragment import com.owncloud.android.ui.preview.PreviewTextFragment import com.owncloud.android.ui.preview.PreviewTextStringFragment @@ -897,7 +895,7 @@ class FileDisplayActivity : } canMediaPreviewed(file) -> { - startMediaPreview(file, 0, true, true, true, true) + startMediaPreview(file, true, true) true } @@ -2087,7 +2085,7 @@ class FileDisplayActivity : startTextPreview(file, false) } else if (canMediaPreviewed(file)) { setFabVisible?.onComplete(false) - startMediaPreview(file, 0, true, true, false, true) + startMediaPreview(file, true, false) } else { fileOperationsHelper.openFile(file) } @@ -2460,22 +2458,6 @@ class FileDisplayActivity : showDetails(ocFile) } - is PreviewMediaFragment -> { - fragment.updateFile(ocFile) - if (PreviewMediaFragment.isAudioOrVideo(ocFile)) { - startMediaPreview( - ocFile, - fragment.position, - true, - true, - true, - false - ) - } else { - fileOperationsHelper.openFile(ocFile) - } - } - is PreviewTextFileFragment -> { fragment.updateFile(ocFile) if (PreviewTextFileFragment.canBePreviewed(ocFile)) { @@ -2740,37 +2722,19 @@ class FileDisplayActivity : } /** - * Stars the preview of an already down media [OCFile]. - * - * @param file Media [OCFile] to preview. - * @param startPlaybackPosition Media position where the playback will be started, in milliseconds. - * @param autoplay When 'true', the playback will start without user interactions. + * Starts the preview of a media [OCFile], synchronizing it first when it is not available yet. */ - fun startMediaPreview( - file: OCFile, - startPlaybackPosition: Long, - autoplay: Boolean, - showPreview: Boolean, - streamMedia: Boolean, - showInActivity: Boolean - ) { + fun startMediaPreview(file: OCFile, showPreview: Boolean, streamMedia: Boolean) { val user = getUser() if (!user.isPresent) { return // not reachable under normal conditions } if ((showPreview && file.isDown && !file.isDownloading) || streamMedia) { - if (showInActivity) { - startMediaActivity(file) - } else { - configureToolbarForPreview(file) - val mediaFragment: Fragment = newInstance(file, user.get(), startPlaybackPosition, autoplay) - setLeftFragment(mediaFragment, false) - } + startMediaActivity(file) } else { val previewIntent = Intent() previewIntent.putExtra(EXTRA_FILE, file) - previewIntent.putExtra(PreviewMediaFragment.EXTRA_START_POSITION, startPlaybackPosition) - previewIntent.putExtra(PreviewMediaFragment.EXTRA_AUTOPLAY, autoplay) + previewIntent.putExtra(MEDIA_PREVIEW, true) val fileOperationsHelper = FileOperationsHelper(this, userAccountManager, connectivityService, editorUtils) fileOperationsHelper.startSyncForFileAndIntent(file, previewIntent) @@ -2976,17 +2940,8 @@ class FileDisplayActivity : if (event.intent.getBooleanExtra(TEXT_PREVIEW, false)) { startTextPreview(file, true) - } else if (bundle.containsKey(PreviewMediaFragment.EXTRA_START_POSITION)) { - val startPosition = bundle.get(PreviewMediaFragment.EXTRA_START_POSITION) as Long - val autoPlay = bundle.get(PreviewMediaFragment.EXTRA_AUTOPLAY) as Boolean - startMediaPreview( - file, - startPosition, - autoPlay, - true, - true, - true - ) + } else if (event.intent.getBooleanExtra(MEDIA_PREVIEW, false)) { + startMediaPreview(file, true, true) } else if (bundle.containsKey(PreviewImageActivity.EXTRA_VIRTUAL_TYPE)) { val virtualType = bundle.get(PreviewImageActivity.EXTRA_VIRTUAL_TYPE) as VirtualFolderType? startImagePreview( @@ -3372,6 +3327,7 @@ class FileDisplayActivity : const val TAG_LIST_OF_FILES: String = "LIST_OF_FILES" const val TEXT_PREVIEW: String = "TEXT_PREVIEW" + const val MEDIA_PREVIEW: String = "MEDIA_PREVIEW" const val KEY_IS_SEARCH_OPEN: String = "IS_SEARCH_OPEN" const val KEY_SEARCH_QUERY: String = "SEARCH_QUERY" diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index a8b2bc317f3f..cf1890fd72a0 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -1207,7 +1207,7 @@ private void handlePendingDownloadFile(OCFile file) { if (!file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda && fda.canMediaPreviewed(file)) { setFabVisible(false); - fda.startMediaPreview(file, 0, true, true, true, true); + fda.startMediaPreview(file, true, true); } else if (webViewAvailable && editorUtils.getEditor(accountManager.getUser(), file.getMimeType()) != null && !file.isEncrypted()) { TextEditorWebView.Companion.startTextEditor(file, getContext()); } else if (supportsDirectEditing(file, webViewAvailable)) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index 16af3b6e45fd..7fd1b90db60c 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -69,7 +69,6 @@ import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.ui.preview.PreviewMediaFragment.Companion.newInstance import com.owncloud.android.utils.BitmapUtils import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil @@ -166,16 +165,11 @@ class PreviewImageFragment : } } - private fun playLivePhoto(file: OCFile?) { - if (file == null) { - return - } - + private fun playLivePhoto(file: OCFile) { hideActionBar() - val mediaFragment: Fragment = newInstance(file, accountManager.user, autoplay = true, isLivePhoto = true) - val fragmentManager = requireActivity().supportFragmentManager - fragmentManager.beginTransaction().run { + val mediaFragment = PreviewPlaybackFragment.newInstance(file, searchType = null, autoplay = true) + requireActivity().supportFragmentManager.beginTransaction().run { replace(R.id.top, mediaFragment) addToBackStack(null) commit() diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt deleted file mode 100644 index cb37f45bb286..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ /dev/null @@ -1,661 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-FileCopyrightText: 2023 TSI-mc - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2016 ownCloud Inc. - * SPDX-FileCopyrightText: 2013 David A. Velasco - * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) - */ -package com.owncloud.android.ui.preview - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.content.res.Configuration -import android.content.res.Resources -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.MotionEvent -import android.view.View -import android.view.View.OnTouchListener -import android.view.ViewGroup -import androidx.annotation.OptIn -import androidx.annotation.StringRes -import androidx.core.net.toUri -import androidx.core.view.MenuHost -import androidx.core.view.MenuProvider -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.drawerlayout.widget.DrawerLayout -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaSession -import androidx.media3.ui.PlayerView -import com.nextcloud.client.account.User -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.di.Injectable -import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.jobs.download.FileDownloadHelper.Companion.instance -import com.nextcloud.client.media.BackgroundPlayerService -import com.nextcloud.client.media.ExoplayerListener -import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.client.network.ClientFactory.CreationException -import com.nextcloud.common.NextcloudClient -import com.nextcloud.ui.fileactions.FileAction -import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance -import com.nextcloud.utils.extensions.applyControlsInsets -import com.nextcloud.utils.extensions.getParcelableArgument -import com.nextcloud.utils.extensions.getTypedActivity -import com.nextcloud.utils.extensions.setFullscreenButton -import com.owncloud.android.R -import com.owncloud.android.databinding.FragmentPreviewMediaBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.files.StreamMediaFileOperation -import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.operations.FetchRemoteFileOperation -import com.owncloud.android.ui.activity.DrawerActivity -import com.owncloud.android.ui.activity.FileActivity -import com.owncloud.android.ui.dialog.ConfirmationDialogFragment -import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment -import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.MimeTypeUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -/** - * This fragment shows a preview of a downloaded media file (audio or video). - * - * Trying to get an instance with NULL [OCFile] or ownCloud [User] values will produce an - * [IllegalStateException]. - * - * By now, if the [OCFile] passed is not downloaded, an [IllegalStateException] is generated on - * instantiation too. - * - * Creates an empty fragment for previews. - * - * MUST BE KEPT: the system uses it when tries to reinstantiate a fragment automatically (for instance, when the - * device is turned a aside). - * - * DO NOT CALL IT: an [OCFile] and [User] must be provided for a successful construction - */ -@Suppress("NestedBlockDepth", "ComplexMethod", "LongMethod", "TooManyFunctions", "ReturnCount") -class PreviewMediaFragment : - FileFragment(), - OnTouchListener, - Injectable { - private var user: User? = null - private var savedPlaybackPosition: Long = 0 - - private var autoplay = true - private var isLivePhoto = false - private val prepared = false - - private var videoUri: Uri? = null - - @Inject - lateinit var clientFactory: ClientFactory - - @Inject - lateinit var accountManager: UserAccountManager - - @Inject - lateinit var backgroundJobManager: BackgroundJobManager - - lateinit var binding: FragmentPreviewMediaBinding - - private val exoplayerView: PlayerView - get() = binding.exoplayerView.root - - private var emptyListView: ViewGroup? = null - private var exoPlayer: ExoPlayer? = null - private var mediaSession: MediaSession? = null - private var nextcloudClient: NextcloudClient? = null - private var isFullscreenActive = false - - @OptIn(UnstableApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // release any background media session if exists - val intent = Intent(BackgroundPlayerService.RELEASE_MEDIA_SESSION_BROADCAST_ACTION).apply { - setPackage(requireActivity().packageName) - } - requireActivity().sendBroadcast(intent) - - arguments?.let { - initArguments(it) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - super.onCreateView(inflater, container, savedInstanceState) - Log_OC.v(TAG, "onCreateView") - - binding = FragmentPreviewMediaBinding.inflate(inflater, container, false) - emptyListView = binding.emptyView.emptyListView - setLoadingView() - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - Log_OC.v(TAG, "onActivityCreated") - - checkArgumentsAfterViewCreation(savedInstanceState) - - toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - addMenuHost() - } - - private fun checkArgumentsAfterViewCreation(savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - checkNotNull(file) { "Instanced with a NULL OCFile" } - checkNotNull(user) { "Instanced with a NULL ownCloud Account" } - } else { - file = savedInstanceState.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - user = savedInstanceState.getParcelableArgument(EXTRA_USER, User::class.java) - savedPlaybackPosition = savedInstanceState.getInt(EXTRA_PLAY_POSITION).toLong() - autoplay = savedInstanceState.getBoolean(EXTRA_PLAYING) - } - } - - private fun initArguments(bundle: Bundle) { - file = bundle.getParcelableArgument(FILE, OCFile::class.java) - user = bundle.getParcelableArgument(USER, User::class.java) - - savedPlaybackPosition = bundle.getLong(PLAYBACK_POSITION) - autoplay = bundle.getBoolean(AUTOPLAY) - isLivePhoto = bundle.getBoolean(IS_LIVE_PHOTO) - } - - override fun onResume() { - super.onResume() - if (isFullscreenActive) { - return - } - applyWindowInsets() - prepareMedia() - } - - @OptIn(UnstableApi::class) - private fun applyWindowInsets() { - binding.root.post { - val rootInsets = ViewCompat.getRootWindowInsets(binding.root) ?: return@post - exoplayerView.applyControlsInsets( - rootInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - ) - ) - } - } - - private fun setLoadingView() { - binding.progress.visibility = View.VISIBLE - binding.emptyView.emptyListView.visibility = View.GONE - } - - private fun setVideoErrorMessage(headline: String, @StringRes message: Int = R.string.stream_not_possible_message) { - binding.emptyView.run { - emptyListViewHeadline.text = headline - emptyListViewText.setText(message) - emptyListIcon.setImageResource(R.drawable.file_movie) - emptyListViewText.visibility = View.VISIBLE - emptyListIcon.visibility = View.VISIBLE - emptyListView.visibility = View.VISIBLE - } - - binding.progress.visibility = View.GONE - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - - outState.run { - putParcelable(EXTRA_FILE, file) - putParcelable(EXTRA_USER, user) - - savedPlaybackPosition = exoPlayer?.currentPosition ?: savedPlaybackPosition - autoplay = exoPlayer?.isPlaying ?: autoplay - putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition) - putBoolean(EXTRA_PLAYING, autoplay) - } - } - - private fun prepareMedia() { - if (file == null || !isAdded) { - Log_OC.d(TAG, "File is null or fragment not attached to a context.") - return - } - prepareForVideo() - } - - @Suppress("DEPRECATION", "TooGenericExceptionCaught") - private fun prepareForVideo() { - if (exoPlayer != null) { - playVideo() - return - } - - lifecycleScope.launch { - try { - val client = withContext(Dispatchers.IO) { - clientFactory.createNextcloudClient(accountManager.user) - } - nextcloudClient = client - val ctx = this@PreviewMediaFragment.context ?: return@launch - - withContext(Dispatchers.Main) { - createExoPlayer(ctx, client) - playVideo() - } - } catch (e: CreationException) { - Log_OC.e(TAG, "error setting up ExoPlayer", e) - } - } - } - - private fun createExoPlayer(context: Context, client: NextcloudClient) { - exoPlayer = createNextcloudExoplayer(context, client) - exoPlayer?.let { - val listener = ExoplayerListener(context, exoplayerView, it) { goBackToLivePhoto() } - it.addListener(listener) - } - mediaSession = MediaSession.Builder( - context, - exoPlayer as Player - ).setId(System.currentTimeMillis().toString()).build() - } - - private fun releaseVideoPlayer() { - exoPlayer?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - it.release() - mediaSession?.release() - } - mediaSession = null - exoPlayer = null - } - - private fun goBackToLivePhoto() { - if (!isLivePhoto) { - return - } - - showActionBar() - requireActivity().supportFragmentManager.popBackStack() - } - - private fun showActionBar() { - val currentActivity: Activity = requireActivity() - if (currentActivity is PreviewImageActivity) { - currentActivity.toggleActionBarVisibility(false) - } - } - - @OptIn(UnstableApi::class) - private fun setupVideoView() { - exoplayerView.run { - setShowNextButton(false) - setShowPreviousButton(false) - player = exoPlayer - setFullscreenButton(isFullscreen = false) { startFullScreenVideo() } - } - } - - private fun addMenuHost() { - val menuHost: MenuHost = requireActivity() - - menuHost.addMenuProvider( - object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menu.removeItem(R.id.action_search) - menuInflater.inflate(R.menu.custom_menu_placeholder, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.custom_menu_placeholder_item -> { - if (containerActivity.storageManager == null || file == null) return false - onOverflowClick() - true - } - - else -> false - } - } - }, - viewLifecycleOwner, - Lifecycle.State.RESUMED - ) - } - - /** - * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails - */ - private fun onOverflowClick(isManualClick: Boolean = false) { - val updatedFile = containerActivity.storageManager.getFileById(file.fileId) - // check for albums file - // for album file both local and remoteId will be same configured at operation level - if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { - fetchFileMetaDataIfAbsent(updatedFile) - } else { - file = updatedFile - file?.let { newFile -> - showFileActions(newFile) - } - } - } - - private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { - if (requireActivity() is FileActivity) { - (requireActivity() as FileActivity).showLoadingDialog(getString(R.string.wait_a_moment)) - } - lifecycleScope.launch(Dispatchers.IO) { - val fetchRemoteFileOperation = - FetchRemoteFileOperation( - requireActivity(), - accountManager.user, - ocFile, - removeFileFromDb = true, - storageManager = containerActivity.storageManager - ) - val result = fetchRemoteFileOperation.execute(requireActivity()) - withContext(Dispatchers.Main) { - if (requireActivity() is FileActivity) { - (requireActivity() as FileActivity).dismissLoadingDialog() - } - if (result?.isSuccess == true && result.resultData != null) { - file = result.resultData as OCFile - - onOverflowClick(isManualClick = true) - } else { - Log_OC.d(TAG, result?.logMessage) - // show error - DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(requireContext())) - } - } - } - } - - private fun showFileActions(file: OCFile) { - val additionalFilter = FileAction.getFilePreviewActions(getFile()) - newInstance(file, false, additionalFilter) - .setResultListener(childFragmentManager, this) { itemId: Int -> this.onFileActionChosen(itemId) } - .show(childFragmentManager, "actions") - } - - private fun onFileActionChosen(itemId: Int) { - when (itemId) { - R.id.action_send_share_file -> { - sendShareFile() - } - - R.id.action_open_file_with -> { - openFile() - } - - R.id.action_remove_file -> { - val dialog = RemoveFilesDialogFragment.newInstance(file) - dialog.show(requireFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION) - } - - R.id.action_see_details -> { - seeDetails() - } - - R.id.action_sync_file -> { - getTypedActivity(FileActivity::class.java)?.showSyncLoadingDialog(file.isFolder) - containerActivity.fileOperationsHelper.syncFileOrFolder(file) - } - - R.id.action_cancel_sync -> { - containerActivity.fileOperationsHelper.cancelTransference(file) - } - - R.id.action_stream_media -> { - containerActivity.fileOperationsHelper.streamMediaFile(file) - } - - R.id.action_export_file -> { - val list = ArrayList() - list.add(file) - containerActivity.fileOperationsHelper.exportFiles( - list, - context, - view, - backgroundJobManager - ) - } - - R.id.action_download_file -> { - instance().downloadFileIfNotStartedBefore(user!!, file) - } - - R.id.action_add_to_album -> { - containerActivity.fileOperationsHelper.addFileToAlbum(listOf(file)) - } - } - } - - /** - * Update the file of the fragment with file value - * - * @param file Replaces the held file with a new one - */ - fun updateFile(file: OCFile?) { - setFile(file) - } - - private fun seeDetails() { - releaseVideoPlayer() - containerActivity.showDetails(file) - } - - private fun sendShareFile() { - releaseVideoPlayer() - containerActivity.fileOperationsHelper.sendShareFile(file) - } - - @Suppress("TooGenericExceptionCaught") - private fun playVideo() { - setupVideoView() - if (file.isDown) { - playVideoUri(file.storageUri) - return - } - - lifecycleScope.launch { - try { - val uri = withContext(Dispatchers.IO) { - loadStreamUrl(user, clientFactory, file.localId) - } - if (uri != null) { - videoUri = uri - playVideoUri(uri) - } else { - emptyListView?.visibility = View.VISIBLE - setVideoErrorMessage(getString(R.string.stream_not_possible_headline)) - } - } catch (e: Exception) { - Log_OC.e(TAG, "Loading stream url not possible: $e") - } - } - } - - private fun loadStreamUrl(user: User?, clientFactory: ClientFactory?, fileId: Long): Uri? { - val client: OwnCloudClient? = try { - clientFactory?.create(user) - } catch (e: CreationException) { - Log_OC.e(TAG, "Loading stream url not possible: $e") - return null - } - - val sfo = StreamMediaFileOperation(fileId) - val result = sfo.execute(client) - - if (result?.isSuccess == false) { - return null - } - - return (result?.data?.get(0) as? String)?.toUri() - } - - private fun playVideoUri(uri: Uri) { - binding.progress.visibility = View.GONE - - exoPlayer?.setMediaItem(MediaItem.fromUri(uri)) - exoPlayer?.playWhenReady = autoplay - exoPlayer?.prepare() - - if (savedPlaybackPosition >= 0) { - exoPlayer?.seekTo(savedPlaybackPosition) - } - - // only autoplay video once - autoplay = false - } - - override fun onPause() { - if (!isFullscreenActive) { - releaseVideoPlayer() - } - super.onPause() - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(v: View, event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_DOWN && v == exoplayerView) { - // added a margin on the left to avoid interfering with gesture to open navigation drawer - if (event.x / Resources.getSystem().displayMetrics.density > MIN_DENSITY_RATIO) { - startFullScreenVideo() - } - return true - } - return false - } - - private fun startFullScreenVideo() { - val activity = activity ?: return - val client = nextcloudClient ?: return - val player = exoPlayer ?: return - - isFullscreenActive = true - val dialog = PreviewVideoFullscreenDialog( - activity, - client, - player, - exoplayerView - ).apply { - setOnDismissListener { - isFullscreenActive = false - setupVideoView() - } - } - - dialog.show() - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - Log_OC.v(TAG, "onConfigurationChanged $this") - } - - @Suppress("DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - Log_OC.v(TAG, "onActivityResult $this") - super.onActivityResult(requestCode, resultCode, data) - if (resultCode == Activity.RESULT_OK) { - savedPlaybackPosition = data?.getLongExtra(EXTRA_START_POSITION, 0) ?: 0L - autoplay = data?.getBooleanExtra(EXTRA_AUTOPLAY, false) ?: false - } - } - - /** - * Opens the previewed file with an external application. - */ - private fun openFile() { - containerActivity.fileOperationsHelper.openFile(file) - } - - val position: Long - get() { - if (prepared) { - savedPlaybackPosition = exoPlayer?.currentPosition ?: 0 - } - Log_OC.v(TAG, "getting position: $savedPlaybackPosition") - return savedPlaybackPosition - } - - private fun toggleDrawerLockMode(containerActivity: ContainerActivity, lockMode: Int) { - (containerActivity as DrawerActivity).setDrawerLockMode(lockMode) - } - - override fun onDetach() { - exoPlayer?.let { - it.stop() - it.release() - } - - super.onDetach() - } - - companion object { - private val TAG: String = PreviewMediaFragment::class.java.simpleName - - const val EXTRA_FILE: String = "FILE" - const val EXTRA_USER: String = "USER" - const val EXTRA_AUTOPLAY: String = "AUTOPLAY" - const val EXTRA_START_POSITION: String = "START_POSITION" - - private const val EXTRA_PLAY_POSITION = "PLAY_POSITION" - private const val EXTRA_PLAYING = "PLAYING" - private const val MIN_DENSITY_RATIO = 24.0 - - private const val FILE = "FILE" - private const val USER = "USER" - private const val PLAYBACK_POSITION = "PLAYBACK_POSITION" - private const val AUTOPLAY = "AUTOPLAY" - private const val IS_LIVE_PHOTO = "IS_LIVE_PHOTO" - - fun newInstance( - fileToDetail: OCFile?, - user: User?, - startPlaybackPosition: Long = 0, - autoplay: Boolean = false, - isLivePhoto: Boolean = false - ): PreviewMediaFragment = PreviewMediaFragment().apply { - arguments = Bundle().apply { - putParcelable(FILE, fileToDetail) - putParcelable(USER, user) - putLong(PLAYBACK_POSITION, startPlaybackPosition) - putBoolean(AUTOPLAY, autoplay) - putBoolean(IS_LIVE_PHOTO, isLivePhoto) - } - } - - fun isAudioOrVideo(file: OCFile?): Boolean = - file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index f3314d634849..fcbfdc49de69 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -20,8 +20,10 @@ import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.fragment.SearchType import com.owncloud.android.utils.FileSortOrder import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeTypeUtil /** * Adapter class that provides Fragment instances @@ -37,6 +39,11 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { private val mStorageManager: FileDataStorageManager private val mCachedFragments: SparseArray + /** + * The collection the pages come from, so that a media page can build the same playback queue. + */ + private val searchType: SearchType? + /** * Constructor * @@ -60,6 +67,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this.user = user this.selectedFile = selectedFile mStorageManager = storageManager + searchType = null mediaFiles = mStorageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) val sortOrder = preferences.getSortOrderByFolder(parentFolder) @@ -93,6 +101,11 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this.user = user mStorageManager = storageManager + searchType = when (type) { + VirtualFolderType.GALLERY -> SearchType.GALLERY_SEARCH + VirtualFolderType.FAVORITE -> SearchType.FAVORITE_SEARCH + else -> null + } if (type == VirtualFolderType.GALLERY) { mediaFiles = mStorageManager.allGalleryItems @@ -156,8 +169,8 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } private fun fragmentForDownloaded(file: OCFile, ignoreFirstSavedState: Boolean): Fragment = - if (PreviewMediaFragment.isAudioOrVideo(file)) { - PreviewMediaFragment.newInstance(file, user) + if (file.isAudioOrVideo()) { + PreviewPlaybackFragment.newInstance(file, searchType) } else { PreviewImageFragment.newInstance(file, ignoreFirstSavedState, false) } @@ -173,13 +186,14 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { // without first being downloaded. file.isEncrypted -> FileDownloadFragment.newInstance(file, user, ignoreFirstSavedState) - PreviewMediaFragment.isAudioOrVideo(file) -> - PreviewMediaFragment.newInstance(file, user) + file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, searchType) else -> PreviewImageFragment.newInstance(file, ignoreFirstSavedState, true) } } + private fun OCFile.isAudioOrVideo(): Boolean = MimeTypeUtil.isAudio(this) || MimeTypeUtil.isVideo(this) + fun getFilePosition(file: OCFile): Int = mediaFiles.indexOf(file) fun updateFile(position: Int, file: OCFile) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt new file mode 100644 index 000000000000..f89d1a5f07df --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -0,0 +1,157 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.preview + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.VideoSize +import com.nextcloud.client.player.ui.PlayerLauncher +import com.nextcloud.client.player.util.applyVideoSize +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.getSerializableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.PreviewPlaybackFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.SearchType +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Plays an audio or video page of the preview pager in place, so that swiping between images and media keeps the + * user on the same screen. Playback itself is owned by the shared [PlaybackModel], the same one that drives + * [com.nextcloud.client.player.ui.PlayerActivity], notification and background playback. + */ +class PreviewPlaybackFragment : + Fragment(), + PlaybackModel.Listener { + + companion object { + private const val ARGUMENT_FILE = "ARGUMENT_FILE" + private const val ARGUMENT_SEARCH_TYPE = "ARGUMENT_SEARCH_TYPE" + private const val ARGUMENT_AUTOPLAY = "ARGUMENT_AUTOPLAY" + + fun newInstance(file: OCFile, searchType: SearchType?, autoplay: Boolean = false) = + PreviewPlaybackFragment().apply { + arguments = bundleOf( + ARGUMENT_FILE to file, + ARGUMENT_SEARCH_TYPE to searchType, + ARGUMENT_AUTOPLAY to autoplay + ) + } + } + + @Inject + lateinit var playbackModel: PlaybackModel + + @Inject + lateinit var playerLauncher: PlayerLauncher + + @Inject + lateinit var thumbnailLoader: ThumbnailLoader + + private lateinit var binding: PreviewPlaybackFragmentBinding + private lateinit var file: OCFile + private lateinit var playbackFile: PlaybackFile + private var searchType: SearchType? = null + private var autoplay: Boolean = false + private var previousVideoSize: VideoSize? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AndroidSupportInjection.inject(this) + file = arguments.getParcelableArgument(ARGUMENT_FILE, OCFile::class.java) + ?: throw IllegalArgumentException("bundle is not containing a file") + playbackFile = file.toPlaybackFile() + searchType = arguments.getSerializableArgument(ARGUMENT_SEARCH_TYPE, SearchType::class.java) + autoplay = arguments?.getBoolean(ARGUMENT_AUTOPLAY) == true + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = PreviewPlaybackFragmentBinding.inflate(inflater, container, false) + loadThumbnail() + return binding.root + } + + override fun onResume() { + super.onResume() + preparePlayback() + playbackModel.addListener(this) + binding.playerControlView.onStart() + render(playbackModel.state) + } + + override fun onPause() { + binding.playerControlView.onStop() + playbackModel.removeListener(this) + if (isCurrentItem(playbackModel.state)) { + playbackModel.pause() + playbackModel.setVideoSurfaceView(null) + } + super.onPause() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + /** + * Reuses the queue the player already holds when it contains this page, so that swiping between media pages + * does not rebuild it. + */ + private fun preparePlayback() { + val state = playbackModel.state + if (state != null && state.currentFiles.any { it.id == playbackFile.id }) { + playbackModel.switchToFile(playbackFile) + if (autoplay) { + playbackModel.play() + } + } else { + playerLauncher.prepare(this, file, searchType, autoplay) + } + } + + private fun isCurrentItem(state: PlaybackState?): Boolean = state?.currentItemState?.file?.id == playbackFile.id + + private fun loadThumbnail() { + viewLifecycleOwner.lifecycleScope.launch { + val context = context ?: return@launch + val size = context.resources.getDimension(R.dimen.player_album_cover_size).toInt() + thumbnailLoader.await(context, playbackFile, size, size)?.let(binding.thumbnail::setImageBitmap) + } + } + + private fun render(state: PlaybackState?) { + if (!isCurrentItem(state)) { + binding.surfaceView.visibility = View.GONE + return + } + showVideo(state?.currentItemState?.videoSize) + } + + private fun showVideo(videoSize: VideoSize?) { + playbackModel.setVideoSurfaceView(binding.surfaceView) + binding.surfaceView.visibility = View.VISIBLE + binding.surfaceView.alpha = if (videoSize != null) 1f else 0f + + if (videoSize != null && previousVideoSize != videoSize) { + previousVideoSize = videoSize + binding.surfaceView.applyVideoSize(videoSize) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt deleted file mode 100644 index 205fd07ebeed..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.ui.preview - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Build -import android.view.ViewGroup -import android.view.Window -import androidx.activity.addCallback -import androidx.annotation.OptIn -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.fragment.app.FragmentActivity -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.PlayerView -import com.nextcloud.client.media.ExoplayerListener -import com.nextcloud.client.media.NextcloudExoPlayer -import com.nextcloud.common.NextcloudClient -import com.nextcloud.utils.extensions.applyControlsInsets -import com.nextcloud.utils.extensions.setFullscreenButton -import com.owncloud.android.R -import com.owncloud.android.databinding.DialogPreviewVideoBinding -import com.owncloud.android.lib.common.utils.Log_OC - -/** - * Transfers a previously playing video to a fullscreen dialog, and handles the switch back to the previous player - * when closed - * - * @param activity the Activity hosting the original non-fullscreen player - * @param sourceExoPlayer the ExoPlayer playing the video - * @param sourceView the original non-fullscreen surface that [sourceExoPlayer] is linked to - */ -@OptIn(UnstableApi::class) -class PreviewVideoFullscreenDialog( - private val activity: FragmentActivity, - nextcloudClient: NextcloudClient, - private val sourceExoPlayer: ExoPlayer, - private val sourceView: PlayerView -) : Dialog(sourceView.context, R.style.Dialog_FullscreenVideo) { - - private val binding: DialogPreviewVideoBinding = DialogPreviewVideoBinding.inflate(layoutInflater) - - private val playerView: PlayerView - get() = binding.videoPlayer.root - - private var playingStateListener: androidx.media3.common.Player.Listener? = null - private var externalDismissListener: DialogInterface.OnDismissListener? = null - private var wasPlayingBeforeDismiss = false - - /** - * exoPlayer instance used for this view, either the original one or a new one in specific cases. - * @see getShouldUseRotatedVideoWorkaround - */ - private val mExoPlayer: ExoPlayer - - /** - * Videos with rotation metadata present a bug in sdk < 30 where they are rotated incorrectly and stretched when - * the video is resumed on a new surface. To work around this, in those circumstances we'll create a new ExoPlayer - * instance, which is slower but should avoid the bug. - */ - private val shouldUseRotatedVideoWorkaround - get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.R && isRotatedVideo() - - init { - addContentView( - binding.root, - ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - ) - mExoPlayer = getExoPlayer(nextcloudClient) - if (shouldUseRotatedVideoWorkaround) { - sourceExoPlayer.currentMediaItem?.let { mExoPlayer.setMediaItem(it, sourceExoPlayer.currentPosition) } - playerView.player = mExoPlayer - mExoPlayer.prepare() - } - super.setOnDismissListener { - restoreSourcePlayer() - externalDismissListener?.onDismiss(this) - } - handleOnBackPressed() - } - - /** - * Keeps the caller's listener instead of letting it replace the internal one, which has to run first to hand the - * playback back to [sourceView]. - */ - override fun setOnDismissListener(listener: DialogInterface.OnDismissListener?) { - externalDismissListener = listener - } - - private fun isRotatedVideo(): Boolean { - val videoFormat = sourceExoPlayer.videoFormat - return videoFormat != null && videoFormat.rotationDegrees != 0 - } - - private fun getExoPlayer(nextcloudClient: NextcloudClient): ExoPlayer = if (shouldUseRotatedVideoWorkaround) { - Log_OC.d(TAG, "Using new ExoPlayer instance to deal with rotated video") - NextcloudExoPlayer - .createNextcloudExoplayer(sourceView.context, nextcloudClient) - .apply { - addListener(ExoplayerListener(sourceView.context, playerView, this)) - } - } else { - sourceExoPlayer - } - - override fun show() { - val isPlaying = sourceExoPlayer.isPlaying - if (isPlaying) { - sourceExoPlayer.pause() - } - setOnShowListener { - enableImmersiveMode() - keepControlsClearOfSystemBars() - switchTargetViewFromSource() - playerView.setFullscreenButton(isFullscreen = true) { - activity.onBackPressedDispatcher.onBackPressed() - } - if (isPlaying) { - mExoPlayer.play() - } - } - super.show() - } - - private fun switchTargetViewFromSource() { - if (shouldUseRotatedVideoWorkaround) { - mExoPlayer.seekTo(sourceExoPlayer.currentPosition) - } else { - PlayerView.switchTargetView(sourceExoPlayer, sourceView, playerView) - } - } - - private fun handleOnBackPressed() { - activity.onBackPressedDispatcher.addCallback(activity) { - wasPlayingBeforeDismiss = mExoPlayer.isPlaying - if (wasPlayingBeforeDismiss) { - mExoPlayer.pause() - } - dismiss() - isEnabled = false - } - } - - private fun restoreSourcePlayer() { - playingStateListener?.let { - mExoPlayer.removeListener(it) - } - switchTargetViewToSource() - if (wasPlayingBeforeDismiss) { - sourceExoPlayer.play() - } - sourceView.showController() - } - - private fun switchTargetViewToSource() { - if (shouldUseRotatedVideoWorkaround) { - sourceExoPlayer.seekTo(mExoPlayer.currentPosition) - } else { - PlayerView.switchTargetView(sourceExoPlayer, playerView, sourceView) - } - } - - private fun enableImmersiveMode() { - val dialogWindow = window ?: return - WindowCompat.setDecorFitsSystemWindows(dialogWindow, false) - hideInset(dialogWindow, WindowInsetsCompat.Type.systemBars()) - } - - private fun hideInset(window: Window, type: Int) { - val windowInsetsController = - WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - windowInsetsController.hide(type) - } - - private fun keepControlsClearOfSystemBars() { - ViewCompat.setOnApplyWindowInsetsListener(playerView) { _, windowInsets -> - playerView.applyControlsInsets( - windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - ) - ) - windowInsets - } - ViewCompat.requestApplyInsets(playerView) - } - - companion object { - private val TAG = PreviewVideoFullscreenDialog::class.simpleName - } -} diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_fast_forward.xml deleted file mode 100644 index a0c35cbe1ca0..000000000000 --- a/app/src/main/res/drawable/ic_fast_forward.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_fast_rewind.xml b/app/src/main/res/drawable/ic_fast_rewind.xml deleted file mode 100644 index 384f99cac3b2..000000000000 --- a/app/src/main/res/drawable/ic_fast_rewind.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml deleted file mode 100644 index 082d5c63db2d..000000000000 --- a/app/src/main/res/drawable/ic_pause.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml deleted file mode 100644 index a4705f2a34a2..000000000000 --- a/app/src/main/res/drawable/ic_skip_next.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml deleted file mode 100644 index 2809243d7b08..000000000000 --- a/app/src/main/res/drawable/ic_skip_previous.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/dialog_preview_video.xml b/app/src/main/res/layout/dialog_preview_video.xml deleted file mode 100644 index c83f884cab8b..000000000000 --- a/app/src/main/res/layout/dialog_preview_video.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/fragment_preview_media.xml b/app/src/main/res/layout/fragment_preview_media.xml deleted file mode 100644 index 1ef72e192495..000000000000 --- a/app/src/main/res/layout/fragment_preview_media.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/media_control.xml b/app/src/main/res/layout/media_control.xml deleted file mode 100644 index 8dcd3b301981..000000000000 --- a/app/src/main/res/layout/media_control.xml +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/preview_playback_fragment.xml b/app/src/main/res/layout/preview_playback_fragment.xml new file mode 100644 index 000000000000..8f791103cc85 --- /dev/null +++ b/app/src/main/res/layout/preview_playback_fragment.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_media_player.xml b/app/src/main/res/layout/view_media_player.xml deleted file mode 100644 index d42e7e3df036..000000000000 --- a/app/src/main/res/layout/view_media_player.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt b/app/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt deleted file mode 100644 index f40e312d9255..000000000000 --- a/app/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioFocusRequest -import android.media.AudioManager -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -class AudioFocusManagerTest { - - private val audioManager: AudioManager = mock() - private val callback: (AudioFocus) -> Unit = mock() - private lateinit var audioFocusManager: AudioFocusManager - - private val builder: AudioFocusRequest.Builder = mock() - private val focusRequest: AudioFocusRequest = mock() - - @Before - fun setUp() { - // Chain mock methods for the builder - whenever(builder.setWillPauseWhenDucked(true)).thenReturn(builder) - whenever(builder.setOnAudioFocusChangeListener(any())).thenReturn(builder) - whenever(builder.build()).thenReturn(focusRequest) - - audioFocusManager = AudioFocusManager(audioManager, callback, builder) - - whenever(audioManager.requestAudioFocus(focusRequest)) - .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) - - whenever(audioManager.abandonAudioFocusRequest(focusRequest)) - .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) - } - - @Test - fun `requestFocus should invoke FOCUS callback when granted`() { - audioFocusManager.requestFocus() - verify(callback).invoke(AudioFocus.FOCUS) - } - - @Test - fun `requestFocus should invoke LOST callback when denied`() { - whenever(audioManager.requestAudioFocus(focusRequest)) - .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_FAILED) - - audioFocusManager.requestFocus() - verify(callback).invoke(AudioFocus.LOST) - } - - @Test - fun `releaseFocus should invoke LOST callback`() { - audioFocusManager.releaseFocus() - verify(callback).invoke(AudioFocus.LOST) - } -} diff --git a/app/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt b/app/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt deleted file mode 100644 index 259cb9119af4..000000000000 --- a/app/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioManager -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Test - -class AudioFocusTest { - private companion object { - const val INVALID_FOCUS = -10000 - } - - @Test - fun `invalid values result in null`() { - val focus = AudioFocus.fromPlatformFocus(INVALID_FOCUS) - assertNull(focus) - } - - @Test - fun `audio focus values are converted`() { - val validValues = listOf( - AudioManager.AUDIOFOCUS_GAIN, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, - AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - ) - validValues.forEach { - val focus = AudioFocus.fromPlatformFocus(-it) - assertNotNull(focus) - } - } -} From d6ba5b290030c5c7ee3576303156023ae40e74c8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 1 Sep 2026 09:26:38 +0200 Subject: [PATCH 27/69] fix lint Signed-off-by: alperozturk96 --- app/src/main/res/drawable/ic_play.xml | 15 --------------- app/src/main/res/values/styles.xml | 5 ----- 2 files changed, 20 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_play.xml diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml deleted file mode 100644 index 4bb382bf7136..000000000000 --- a/app/src/main/res/drawable/ic_play.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b391602f066d..e7876d4020a3 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -509,11 +509,6 @@ 0dp - - From 61753c6f20bfd7978445a68c762cd97f5c8b2337 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 1 Sep 2026 13:28:23 +0200 Subject: [PATCH 28/69] update icons use m3 Signed-off-by: alperozturk96 --- .../session/MediaSessionBitmapLoader.kt | 4 +-- .../player_ic_notification_audio.xml | 21 ------------- .../player_ic_notification_video.xml | 21 ------------- app/src/main/res/drawable/player_ic_audio.xml | 29 +++++++++++------- app/src/main/res/drawable/player_ic_close.xml | 20 +++++++++---- .../drawable/player_ic_notification_audio.xml | 15 ---------- .../drawable/player_ic_notification_video.xml | 15 ---------- app/src/main/res/drawable/player_ic_pause.xml | 17 ++++++++--- app/src/main/res/drawable/player_ic_play.xml | 23 ++++++++++---- .../main/res/drawable/player_ic_repeat.xml | 22 ++++++++++---- .../main/res/drawable/player_ic_shuffle.xml | 22 ++++++++++---- .../main/res/drawable/player_ic_skip_next.xml | 30 ++++++++++++------- .../res/drawable/player_ic_skip_previous.xml | 30 ++++++++++++------- app/src/main/res/drawable/player_ic_video.xml | 16 ---------- .../res/drawable/player_progress_drawable.xml | 18 +++++++++-- .../res/drawable/player_progress_thumb.xml | 9 ++++-- .../player_progress_thumb_pressed.xml | 29 ++++++++++++++++++ .../player_progress_thumb_selector.xml | 15 ++++++++++ .../res/layout/player_video_file_fragment.xml | 2 +- .../res/layout/preview_playback_fragment.xml | 2 +- app/src/main/res/values/colors.xml | 3 ++ app/src/main/res/values/styles.xml | 9 +++++- 22 files changed, 216 insertions(+), 156 deletions(-) delete mode 100644 app/src/main/res/drawable-v33/player_ic_notification_audio.xml delete mode 100644 app/src/main/res/drawable-v33/player_ic_notification_video.xml delete mode 100644 app/src/main/res/drawable/player_ic_notification_audio.xml delete mode 100644 app/src/main/res/drawable/player_ic_notification_video.xml delete mode 100644 app/src/main/res/drawable/player_ic_video.xml create mode 100644 app/src/main/res/drawable/player_progress_thumb_pressed.xml create mode 100644 app/src/main/res/drawable/player_progress_thumb_selector.xml diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt index c4a5c466a19a..cd49ae45d214 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -96,9 +96,9 @@ class MediaSessionBitmapLoader @Inject constructor( private fun getDefaultBitmap(file: PlaybackFile?): Bitmap { val drawable = if (file != null && MimeTypeUtil.isVideo(file.mimeType)) { - ContextCompat.getDrawable(context, R.drawable.player_ic_notification_video) + ContextCompat.getDrawable(context, R.drawable.video_white) } else { - ContextCompat.getDrawable(context, R.drawable.player_ic_notification_audio) + ContextCompat.getDrawable(context, R.drawable.player_ic_audio) } return drawable?.toBitmap() ?: throw IllegalStateException("Could not decode resource") } diff --git a/app/src/main/res/drawable-v33/player_ic_notification_audio.xml b/app/src/main/res/drawable-v33/player_ic_notification_audio.xml deleted file mode 100644 index 49bf8fc8e7d5..000000000000 --- a/app/src/main/res/drawable-v33/player_ic_notification_audio.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable-v33/player_ic_notification_video.xml b/app/src/main/res/drawable-v33/player_ic_notification_video.xml deleted file mode 100644 index 747fe4f3e90f..000000000000 --- a/app/src/main/res/drawable-v33/player_ic_notification_video.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/player_ic_audio.xml b/app/src/main/res/drawable/player_ic_audio.xml index f38528439cbc..ff60b2bdbb9a 100644 --- a/app/src/main/res/drawable/player_ic_audio.xml +++ b/app/src/main/res/drawable/player_ic_audio.xml @@ -1,16 +1,25 @@ - + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + + android:fillColor="@android:color/white" + android:pathData="M12,3v9.28c-0.47,-0.17 -0.97,-0.28 -1.5,-0.28C8.01,12 6,14.01 6,16.5S8.01,21 10.5,21c2.31,0 4.2,-1.75 4.45,-4H15V6h4V3h-7z" /> diff --git a/app/src/main/res/drawable/player_ic_close.xml b/app/src/main/res/drawable/player_ic_close.xml index 58190e40fcf8..4388c7beb381 100644 --- a/app/src/main/res/drawable/player_ic_close.xml +++ b/app/src/main/res/drawable/player_ic_close.xml @@ -1,16 +1,26 @@ - + + diff --git a/app/src/main/res/drawable/player_ic_notification_audio.xml b/app/src/main/res/drawable/player_ic_notification_audio.xml deleted file mode 100644 index 75ee62145e8f..000000000000 --- a/app/src/main/res/drawable/player_ic_notification_audio.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/player_ic_notification_video.xml b/app/src/main/res/drawable/player_ic_notification_video.xml deleted file mode 100644 index def8fbc18cb7..000000000000 --- a/app/src/main/res/drawable/player_ic_notification_video.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/player_ic_pause.xml b/app/src/main/res/drawable/player_ic_pause.xml index c756bb9e0f11..7da1478c0e5b 100644 --- a/app/src/main/res/drawable/player_ic_pause.xml +++ b/app/src/main/res/drawable/player_ic_pause.xml @@ -1,9 +1,18 @@ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + + android:fillColor="@android:color/white" + android:pathData="M380,660L660,480L380,300L380,660ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" /> + diff --git a/app/src/main/res/drawable/player_ic_repeat.xml b/app/src/main/res/drawable/player_ic_repeat.xml index 5a8b3c1eca35..e89e383a28f1 100644 --- a/app/src/main/res/drawable/player_ic_repeat.xml +++ b/app/src/main/res/drawable/player_ic_repeat.xml @@ -1,16 +1,26 @@ - + + android:fillColor="@android:color/white" + android:pathData="M280,880L120,720L280,560L336,618L274,680L680,680L680,520L760,520L760,760L274,760L336,822L280,880ZM200,440L200,200L686,200L624,138L680,80L840,240L680,400L624,342L686,280L280,280L280,440L200,440Z" /> + diff --git a/app/src/main/res/drawable/player_ic_shuffle.xml b/app/src/main/res/drawable/player_ic_shuffle.xml index bdbd7182d917..23d0263fc89e 100644 --- a/app/src/main/res/drawable/player_ic_shuffle.xml +++ b/app/src/main/res/drawable/player_ic_shuffle.xml @@ -1,16 +1,26 @@ - + + android:fillColor="@android:color/white" + android:pathData="M560,800L560,720L664,720L537,593L594,536L720,662L720,560L800,560L800,800L560,800ZM216,800L160,744L664,240L560,240L560,160L800,160L800,400L720,400L720,296L216,800ZM367,423L160,216L216,160L423,367L367,423Z" /> + diff --git a/app/src/main/res/drawable/player_ic_skip_next.xml b/app/src/main/res/drawable/player_ic_skip_next.xml index bad7839dfb7a..58309fb70b54 100644 --- a/app/src/main/res/drawable/player_ic_skip_next.xml +++ b/app/src/main/res/drawable/player_ic_skip_next.xml @@ -1,16 +1,26 @@ - + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + + android:fillColor="@android:color/white" + android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" /> + diff --git a/app/src/main/res/drawable/player_ic_skip_previous.xml b/app/src/main/res/drawable/player_ic_skip_previous.xml index 088cf2a2b64d..e3a013fd3674 100644 --- a/app/src/main/res/drawable/player_ic_skip_previous.xml +++ b/app/src/main/res/drawable/player_ic_skip_previous.xml @@ -1,16 +1,26 @@ - + android:height="24dp" + android:viewportHeight="24" + android:viewportWidth="24" + android:width="24dp"> + + android:fillColor="@android:color/white" + android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z" /> + diff --git a/app/src/main/res/drawable/player_ic_video.xml b/app/src/main/res/drawable/player_ic_video.xml deleted file mode 100644 index 04aa20a161d0..000000000000 --- a/app/src/main/res/drawable/player_ic_video.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/player_progress_drawable.xml b/app/src/main/res/drawable/player_progress_drawable.xml index 9d2a7fe5a8b9..e5be77b41f13 100644 --- a/app/src/main/res/drawable/player_progress_drawable.xml +++ b/app/src/main/res/drawable/player_progress_drawable.xml @@ -1,11 +1,11 @@ - + @@ -13,6 +13,17 @@ + + + + + + + + + + + @@ -22,4 +33,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_progress_thumb.xml b/app/src/main/res/drawable/player_progress_thumb.xml index e7a6b68f805b..59b7d9087570 100644 --- a/app/src/main/res/drawable/player_progress_thumb.xml +++ b/app/src/main/res/drawable/player_progress_thumb.xml @@ -1,10 +1,9 @@ - @@ -13,4 +12,8 @@ android:height="@dimen/player_progress_thumb_size" /> + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_progress_thumb_pressed.xml b/app/src/main/res/drawable/player_progress_thumb_pressed.xml new file mode 100644 index 000000000000..034ea94b38bf --- /dev/null +++ b/app/src/main/res/drawable/player_progress_thumb_pressed.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_progress_thumb_selector.xml b/app/src/main/res/drawable/player_progress_thumb_selector.xml new file mode 100644 index 000000000000..dc274c5987b4 --- /dev/null +++ b/app/src/main/res/drawable/player_progress_thumb_selector.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/app/src/main/res/layout/player_video_file_fragment.xml b/app/src/main/res/layout/player_video_file_fragment.xml index d31b9e3c05f4..9f126e01ea20 100644 --- a/app/src/main/res/layout/player_video_file_fragment.xml +++ b/app/src/main/res/layout/player_video_file_fragment.xml @@ -16,7 +16,7 @@ android:layout_height="@dimen/player_file_icon_size" android:layout_gravity="center" android:importantForAccessibility="no" - android:src="@drawable/player_ic_video" /> + android:src="@drawable/video_white" /> + android:src="@drawable/video_white" /> #21000000 #21000000 #979797 + #33000000 + #40FFFFFF + #CFCFCF diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index e7876d4020a3..c079ce2295b5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -526,7 +526,9 @@ @@ -551,4 +553,9 @@ textStart 0dp + + From b5e24ae6236b3edd2bc3ddf4a5ea7e3b0633e865 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 1 Sep 2026 13:52:29 +0200 Subject: [PATCH 29/69] update player_audio_file_fragment.xml m3 Signed-off-by: alperozturk96 --- .../player/ui/audio/AudioFileFragment.kt | 77 +++++++++++-------- .../res/layout/player_audio_file_fragment.xml | 63 ++++++++------- app/src/main/res/values/styles.xml | 12 +++ 3 files changed, 90 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt index 790d4d5bf9a9..677450fe45fd 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -19,6 +19,7 @@ import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.state.PlaybackItemMetadata import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R import com.owncloud.android.databinding.PlayerAudioFileFragmentBinding import com.owncloud.android.utils.DisplayUtils @@ -33,6 +34,7 @@ open class AudioFileFragment : companion object { private const val ARGUMENT_FILE = "ARGUMENT_FILE" + private const val DETAILS_SEPARATOR = ", " fun createInstance(file: PlaybackFile) = AudioFileFragment().apply { arguments = bundleOf(ARGUMENT_FILE to file) @@ -45,24 +47,33 @@ open class AudioFileFragment : @Inject lateinit var thumbnailLoader: ThumbnailLoader - private lateinit var binding: PlayerAudioFileFragmentBinding - private lateinit var loadFileThumbnailJob: Job + private var _binding: PlayerAudioFileFragmentBinding? = null + private val binding get() = checkNotNull(_binding) { "Binding accessed outside of the view lifecycle" } + + private var fileThumbnailJob: Job? = null private var isFileThumbnailLoaded = false private var metadata: PlaybackItemMetadata? = null - private val file by lazy { arguments?.getSerializable(ARGUMENT_FILE) as PlaybackFile } + + private val file by lazy { + requireNotNull(arguments.getSerializableArgument(ARGUMENT_FILE, PlaybackFile::class.java)) { + "AudioFileFragment requires a $ARGUMENT_FILE argument" + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) AndroidSupportInjection.inject(this) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - binding = PlayerAudioFileFragmentBinding.inflate(inflater, container, false) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + PlayerAudioFileFragmentBinding.inflate(inflater, container, false).also { _binding = it }.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) binding.title.isSelected = true binding.title.text = file.getNameWithoutExtension() binding.fileDetails.text = file.getDetailsText() - loadFileThumbnailJob = loadFileThumbnail() - return binding.getRoot() + fileThumbnailJob = loadFileThumbnail() } override fun onStart() { @@ -76,34 +87,41 @@ open class AudioFileFragment : super.onStop() } + override fun onDestroyView() { + fileThumbnailJob = null + _binding = null + super.onDestroyView() + } + override fun onPlaybackUpdate(state: PlaybackState) { - state.currentItemState?.let { - if (it.file.id == file.id && it.metadata != null && it.metadata != metadata) { - onMetadataUpdate(it.metadata) - } - } + val itemState = state.currentItemState?.takeIf { it.file.id == file.id } ?: return + val newMetadata = itemState.metadata?.takeIf { it != metadata } ?: return + onMetadataUpdate(newMetadata) } private fun onMetadataUpdate(metadata: PlaybackItemMetadata) { this.metadata = metadata - if (!isFileThumbnailLoaded && (metadata.artworkData != null || metadata.artworkUri != null)) { - loadFileThumbnailJob.takeIf { it.isActive }?.cancel() + + if (!isFileThumbnailLoaded && metadata.hasArtwork()) { + fileThumbnailJob?.cancel() loadMetadataArtwork(metadata) } - binding.title.text = if (metadata.artist.isNullOrEmpty()) { - metadata.title - } else { - "${metadata.artist} • ${metadata.title}" - } + + binding.title.text = metadata.toTitleText() + } + + private fun PlaybackItemMetadata.hasArtwork(): Boolean = artworkData != null || artworkUri != null + + private fun PlaybackItemMetadata.toTitleText(): CharSequence = when { + artist.isNullOrEmpty() -> title + else -> getString(R.string.player_audio_title_with_artist, artist, title) } private fun loadFileThumbnail(): Job = viewLifecycleOwner.lifecycleScope.launch { - val thumbnailSize = resources.getDimension(R.dimen.player_album_cover_size).toInt() - val thumbnail = thumbnailLoader.await(requireContext(), file, thumbnailSize, thumbnailSize) - if (thumbnail != null) { - binding.albumCover.setImageBitmap(thumbnail) - isFileThumbnailLoaded = true - } + val thumbnailSize = resources.getDimensionPixelSize(R.dimen.player_album_cover_size) + val thumbnail = thumbnailLoader.await(requireContext(), file, thumbnailSize, thumbnailSize) ?: return@launch + binding.albumCover.setImageBitmap(thumbnail) + isFileThumbnailLoaded = true } private fun loadMetadataArtwork(metadata: PlaybackItemMetadata) { @@ -111,11 +129,10 @@ open class AudioFileFragment : thumbnailLoader.load(binding.albumCover, source, file.id) } - private fun PlaybackFile.getDetailsText(): String { - val size = if (contentLength > 0) DisplayUtils.bytesToHumanReadable(contentLength) else "" - val date = if (lastModified > 0) getLastModifiedText(lastModified) else "" - return if (size.isNotEmpty() && date.isNotEmpty()) "$size, $date" else size + date - } + private fun PlaybackFile.getDetailsText(): String = listOfNotNull( + contentLength.takeIf { it > 0 }?.let { DisplayUtils.bytesToHumanReadable(it) }, + lastModified.takeIf { it > 0 }?.let(::getLastModifiedText) + ).joinToString(DETAILS_SEPARATOR) private fun getLastModifiedText(lastModified: Long): String { val relativeTimestamp = DisplayUtils.getRelativeTimestamp(context, lastModified) diff --git a/app/src/main/res/layout/player_audio_file_fragment.xml b/app/src/main/res/layout/player_audio_file_fragment.xml index d4ca5a968578..df316b4af3cf 100644 --- a/app/src/main/res/layout/player_audio_file_fragment.xml +++ b/app/src/main/res/layout/player_audio_file_fragment.xml @@ -1,12 +1,12 @@ + app:layout_constraintBottom_toTopOf="@id/title" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHeight_max="@dimen/player_file_icon_size" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintWidth_max="@dimen/player_file_icon_size" /> - + android:importantForAccessibility="no" + app:layout_constraintBottom_toTopOf="@id/title" + app:layout_constraintDimensionRatio="1:1" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintWidth_max="@dimen/player_album_cover_size" + app:shapeAppearance="@style/ShapeAppearance.App.Player.AlbumCover" /> - + android:textAppearance="@style/TextAppearance.App.Player.AudioTitle" + app:layout_constraintBottom_toTopOf="@id/file_details" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + tools:text="Artist name • Track title" /> - + android:textAppearance="@style/TextAppearance.App.Player.AudioFileDetails" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + tools:text="12.3 MB, Modified: 2 days ago" /> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c079ce2295b5..1c7bf2f9068d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -524,6 +524,18 @@ + + + + + + + + + + + - From dd9c094cc4dd9ec8498789c9fed9518ff0ee67f2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 1 Sep 2026 18:19:18 +0200 Subject: [PATCH 40/69] wip Signed-off-by: alperozturk96 --- .../client/player/media3/PlaybackModel.kt | 12 ++++ .../client/player/ui/PlayerActivity.kt | 59 +++++++++++++++++-- .../client/player/ui/video/VideoPlayerView.kt | 5 ++ .../ui/preview/PreviewImageActivity.kt | 6 +- .../ui/preview/PreviewMediaPagerAdapter.kt | 15 ++++- .../ui/preview/PreviewPlaybackFragment.kt | 34 +++++++++++ 6 files changed, 124 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index 3c2d3ec44a70..0452109646d1 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -79,6 +79,7 @@ class PlaybackModel @Inject constructor( private val controllerListener = object : MediaController.Listener { override fun onDisconnected(controller: MediaController) { controller.removeListener(playerListener) + videoSurfaceView = null invalidateCurrentFiles() controllerScope?.cancel() checkProgressPeriodicAction.stop() @@ -91,6 +92,10 @@ class PlaybackModel @Inject constructor( private var mediaSession: MediaSession? = null + var onPictureInPictureClose: (() -> Unit)? = null + + private var videoSurfaceView: SurfaceView? = null + private var cachedCurrentFiles: List? = null private var cachedMediaIds: List? = null @@ -118,6 +123,7 @@ class PlaybackModel @Inject constructor( } suspend fun start() { + videoSurfaceView = null val sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) controller = MediaController.Builder(context, sessionToken) .setListener(controllerListener) @@ -171,6 +177,7 @@ class PlaybackModel @Inject constructor( .let { if (it in 0..files.list.lastIndex) it else 0 } fun release() { + videoSurfaceView = null controller?.release() mediaSession?.player?.release() mediaSession?.release() @@ -178,6 +185,11 @@ class PlaybackModel @Inject constructor( } fun setVideoSurfaceView(surfaceView: SurfaceView?) { + if (videoSurfaceView === surfaceView) { + return + } + + videoSurfaceView = surfaceView controller?.setVideoSurfaceView(surfaceView) } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 1527c09911f5..6a3fa237ca98 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -54,12 +54,23 @@ class PlayerActivity : companion object { private const val PLAYBACK_FILE_TYPE: String = "PLAYBACK_FILE_TYPE" + private const val ENTER_PICTURE_IN_PICTURE: String = "ENTER_PICTURE_IN_PICTURE" + private const val RESUME_PLAYBACK: String = "RESUME_PLAYBACK" fun createIntent(context: Context, playbackFileType: PlaybackFileType): Intent = Intent(context, PlayerActivity::class.java).apply { putExtra(PLAYBACK_FILE_TYPE, playbackFileType) addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) } + + fun createPictureInPictureIntent( + context: Context, + playbackFileType: PlaybackFileType, + resumePlayback: Boolean + ): Intent = createIntent(context, playbackFileType).apply { + putExtra(ENTER_PICTURE_IN_PICTURE, true) + putExtra(RESUME_PLAYBACK, resumePlayback) + } } @Inject @@ -75,12 +86,18 @@ class PlayerActivity : private var onBackPressedCallback: OnBackPressedCallback? = null + private var keepPlaybackAliveOnFinish = false + + private var enterPictureInPictureOnResume = false + private var resumePlaybackOnResume = false + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, windowInsets -> windowInsets } playbackFileType = intent.getPlaybackFileType() + readPictureInPictureRequest(intent) createPlayerView() viewModel.eventFlow @@ -105,6 +122,7 @@ class PlayerActivity : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) playbackFileType = intent.getPlaybackFileType() + readPictureInPictureRequest(intent) recreatePlayerView() onBackPressedCallback?.isEnabled = canUsePictureInPictureMode() } @@ -134,6 +152,26 @@ class PlayerActivity : playerView.onStart() } + override fun onResume() { + super.onResume() + if (!enterPictureInPictureOnResume) { + return + } + enterPictureInPictureOnResume = false + + if (resumePlaybackOnResume) { + playbackModel.play() + } + if (canUsePictureInPictureMode()) { + switchToPictureInPictureMode() + } + } + + private fun readPictureInPictureRequest(intent: Intent) { + enterPictureInPictureOnResume = intent.getBooleanExtra(ENTER_PICTURE_IN_PICTURE, false) + resumePlaybackOnResume = intent.getBooleanExtra(RESUME_PLAYBACK, false) + } + override fun onStop() { super.onStop() playerView.onStop() @@ -141,19 +179,22 @@ class PlayerActivity : override fun onDestroy() { super.onDestroy() - if (isFinishing && playbackFileType == PlaybackFileType.VIDEO) { + playbackModel.onPictureInPictureClose = null + if (isFinishing && !keepPlaybackAliveOnFinish && playbackFileType == PlaybackFileType.VIDEO) { playbackModel.release() } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - recreatePlayerView() + if (isInPictureInPictureMode) { (playerView as? VideoPlayerView)?.hideControls() - } else { - (playerView as? VideoPlayerView)?.showControls() + return } + + recreatePlayerView() + (playerView as? VideoPlayerView)?.showControls() } override fun onUserLeaveHint() { @@ -165,6 +206,16 @@ class PlayerActivity : override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + + playbackModel.onPictureInPictureClose = if (isInPictureInPictureMode) { + { + keepPlaybackAliveOnFinish = true + finish() + } + } else { + null + } + if (!isInPictureInPictureMode && lifecycle.currentState == Lifecycle.State.CREATED) { finish() // Finish the activity if the user closes the PIP window } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt index af265087d9ec..f92eeb0fcc44 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt @@ -90,6 +90,11 @@ class VideoPlayerView(context: Context) : PlayerView(context) { private fun restartHideControlsTimer() { hideControlsTimerJob?.cancel() + + if (activity.isInPictureInPictureMode) { + return + } + hideControlsTimerJob = activity.lifecycleScope.launch { delay(HIDE_CONTROLS_DELAY) hideControls() diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 703dcc4b9f3c..282b54e8ef9c 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -212,6 +212,10 @@ class PreviewImageActivity : } position = position?.toDouble()?.let { max(it, 0.0).toInt() } + if (savedPosition == null) { + previewMediaPagerAdapter?.autoplayFileId = file?.fileId + } + viewPager?.adapter = previewMediaPagerAdapter viewPager?.registerOnPageChangeCallback(object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { @@ -276,7 +280,7 @@ class PreviewImageActivity : if (isDrawerOpen) { closeDrawer() } else { - backToDisplayActivity() + onBackPressedDispatcher.onBackPressed() } return true diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index fcbfdc49de69..77ffe7168d06 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -30,6 +30,8 @@ import com.owncloud.android.utils.MimeTypeUtil */ class PreviewMediaPagerAdapter : FragmentStateAdapter { + var autoplayFileId: Long? = null + private var selectedFile: OCFile? = null private var mediaFiles: MutableList = mutableListOf() private val user: User @@ -170,7 +172,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { private fun fragmentForDownloaded(file: OCFile, ignoreFirstSavedState: Boolean): Fragment = if (file.isAudioOrVideo()) { - PreviewPlaybackFragment.newInstance(file, searchType) + PreviewPlaybackFragment.newInstance(file, searchType, takeAutoplay(file)) } else { PreviewImageFragment.newInstance(file, ignoreFirstSavedState, false) } @@ -186,12 +188,21 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { // without first being downloaded. file.isEncrypted -> FileDownloadFragment.newInstance(file, user, ignoreFirstSavedState) - file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, searchType) + file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, searchType, takeAutoplay(file)) else -> PreviewImageFragment.newInstance(file, ignoreFirstSavedState, true) } } + private fun takeAutoplay(file: OCFile): Boolean { + if (file.fileId != autoplayFileId) { + return false + } + + autoplayFileId = null + return true + } + private fun OCFile.isAudioOrVideo(): Boolean = MimeTypeUtil.isAudio(this) || MimeTypeUtil.isVideo(this) fun getFilePosition(file: OCFile): Int = mediaFiles.indexOf(file) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index f89d1a5f07df..9a681f5d26d7 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -11,6 +11,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -18,16 +20,21 @@ import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState import com.nextcloud.client.player.model.state.VideoSize +import com.nextcloud.client.player.ui.PlayerActivity import com.nextcloud.client.player.ui.PlayerLauncher import com.nextcloud.client.player.util.applyVideoSize +import com.nextcloud.client.player.util.isPictureInPictureAllowed import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R import com.owncloud.android.databinding.PreviewPlaybackFragmentBinding import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.utils.MimeTypeUtil import dagger.android.support.AndroidSupportInjection import kotlinx.coroutines.launch import javax.inject.Inject @@ -72,6 +79,8 @@ class PreviewPlaybackFragment : private var autoplay: Boolean = false private var previousVideoSize: VideoSize? = null + private var pictureInPictureCallback: OnBackPressedCallback? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) AndroidSupportInjection.inject(this) @@ -85,11 +94,35 @@ class PreviewPlaybackFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = PreviewPlaybackFragmentBinding.inflate(inflater, container, false) loadThumbnail() + registerPictureInPictureOnBack() return binding.root } + private fun registerPictureInPictureOnBack() { + if (!MimeTypeUtil.isVideo(file) || !requireContext().isPictureInPictureAllowed()) { + return + } + + pictureInPictureCallback = requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + enabled = false + ) { + val resumePlayback = playbackModel.state?.currentItemState?.playerState == PlayerState.PLAYING + startActivity( + PlayerActivity.createPictureInPictureIntent( + requireContext(), + PlaybackFileType.VIDEO, + resumePlayback + ) + ) + requireActivity().finish() + } + } + override fun onResume() { super.onResume() + playbackModel.onPictureInPictureClose?.invoke() + pictureInPictureCallback?.isEnabled = true preparePlayback() playbackModel.addListener(this) binding.playerControlView.onStart() @@ -97,6 +130,7 @@ class PreviewPlaybackFragment : } override fun onPause() { + pictureInPictureCallback?.isEnabled = false binding.playerControlView.onStop() playbackModel.removeListener(this) if (isCurrentItem(playbackModel.state)) { From 06ab182c96c2bf764caf4ff86b3f2b4582f4732e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 08:19:08 +0200 Subject: [PATCH 41/69] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/client/player/ui/pager/PlayerPager.kt | 1 + .../nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt | 2 ++ app/src/main/res/values/styles.xml | 5 ----- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt index 5b9f3ce67f4f..4484d3f4c893 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -31,6 +31,7 @@ private const val OFFSCREEN_PAGE_LIMIT = 1 class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { + @SuppressLint("WrongConstant") private val viewPager = ViewPager2(context).apply { id = R.id.player_view_pager offscreenPageLimit = OFFSCREEN_PAGE_LIMIT diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt index 6e7bebafbc1a..cdac8a4f7624 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt @@ -7,6 +7,7 @@ package com.nextcloud.client.player.ui.pager +import android.annotation.SuppressLint import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle @@ -27,6 +28,7 @@ class PlayerPagerAdapter( fun getEntities(): List = if (isPadded()) removeStubs(paddedEntities) else paddedEntities + @SuppressLint("NotifyDataSetChanged") fun setEntities(entities: List) { paddedEntities = if (entities.size > 1) addStubs(entities) else entities.toMutableList() notifyDataSetChanged() diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c617c7c91e25..218a002a027b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -581,9 +581,4 @@ textStart 0dp - - From f5a4983384abbebf1c831ed7bd0a25833829337a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 08:30:28 +0200 Subject: [PATCH 42/69] fix "null" in the notification Signed-off-by: alperozturk96 --- .../client/player/media3/MediaNotificationProvider.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt index 8d2f7299bddf..1c85af123865 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt @@ -16,10 +16,9 @@ import com.nextcloud.client.player.media3.common.playbackFile @UnstableApi class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { - override fun getNotificationContentTitle(metadata: MediaMetadata): CharSequence? = - if (metadata.title.isNullOrEmpty()) { - metadata.playbackFile?.getNameWithoutExtension() - } else { - metadata.title - } + override fun getNotificationContentTitle(metadata: MediaMetadata): CharSequence = + metadata.title ?: metadata.playbackFile?.getNameWithoutExtension() ?: "" + + override fun getNotificationContentText(metadata: MediaMetadata): CharSequence = + metadata.artist ?: metadata.albumTitle ?: "" } From 2e51293f70a7ce00fbaf31ac62cbef0b35385dd4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 08:33:18 +0200 Subject: [PATCH 43/69] remove unnecessary tests Signed-off-by: alperozturk96 --- .../file/PlaybackFileSerializationTest.kt | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFileSerializationTest.kt diff --git a/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFileSerializationTest.kt b/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFileSerializationTest.kt deleted file mode 100644 index 3e2c61233f94..000000000000 --- a/app/src/test/java/com/nextcloud/client/player/model/file/PlaybackFileSerializationTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model.file - -import kotlinx.serialization.json.Json -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class PlaybackFileSerializationTest { - - private val json = Json { ignoreUnknownKeys = true } - - private val playbackFile = PlaybackFile( - id = "42", - uri = "https://cloud.example.com/remote.php/dav/files/user/track.mp3", - name = "track.mp3", - mimeType = "audio/mpeg", - contentLength = 8_474_624L, - lastModified = 1_718_236_800_000L, - isFavorite = true - ) - - @Test - fun roundTripsThroughJson() { - val decoded = json.decodeFromString(json.encodeToString(playbackFile)) - - assertEquals(playbackFile, decoded) - } - - @Test - fun unknownKeysFromAnotherAppVersionAreIgnored() { - val encoded = json.encodeToString(playbackFile).dropLast(1) + ""","aFieldAddedLater":true}""" - - assertEquals(playbackFile, json.decodeFromString(encoded)) - } - - @Test - fun malformedPayloadDecodesToNull() { - val decoded = runCatching { json.decodeFromString("not json") }.getOrNull() - - assertNull(decoded) - } -} From 6e16f4874ce37d2ccf1736e5046d372687e4ce7a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 08:47:29 +0200 Subject: [PATCH 44/69] use PlayerUtil.kt Signed-off-by: alperozturk96 --- .../media3/MediaNotificationProvider.kt | 2 +- .../client/player/media3/PlaybackModel.kt | 13 +- .../client/player/media3/PlaybackService.kt | 2 +- .../player/media3/PlaybackStateMapper.kt | 79 ----- .../client/player/media3/common/MediaItem.kt | 20 -- .../player/media3/common/MediaMetadata.kt | 25 -- .../client/player/media3/common/Player.kt | 60 ---- .../media3/datasource/PlaybackDataSource.kt | 2 +- .../resumption/PlaybackResumptionLauncher.kt | 4 +- .../session/MediaSessionBitmapLoader.kt | 2 +- .../player/model/file/PlaybackFileMapper.kt | 38 --- .../model/file/PlaybackFileUriMapper.kt | 28 -- .../model/file/PlaybackFilesRepository.kt | 3 +- .../client/player/ui/PlayerActivity.kt | 2 +- .../client/player/ui/PlayerLauncher.kt | 2 +- .../player/ui/PlayerProgressIndicator.kt | 2 +- .../player/ui/audio/AudioFileFragment.kt | 4 +- .../client/player/ui/pager/PlayerPager.kt | 2 +- .../player/ui/video/VideoFileFragment.kt | 6 +- .../nextcloud/client/player/util/Bundle.kt | 23 -- .../client/player/util/ContentResolver.kt | 26 -- .../nextcloud/client/player/util/Context.kt | 30 -- .../com/nextcloud/client/player/util/List.kt | 16 - .../client/player/util/PlayerUtil.kt | 300 ++++++++++++++++++ .../client/player/util/ScreenUtils.kt | 35 -- .../client/player/util/SurfaceView.kt | 32 -- .../ui/preview/PreviewPlaybackFragment.kt | 6 +- 27 files changed, 328 insertions(+), 436 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/util/Bundle.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/util/Context.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/util/List.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt delete mode 100644 app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt diff --git a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt index 1c85af123865..c656dd4fc40f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt @@ -11,7 +11,7 @@ import android.content.Context import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi import androidx.media3.session.DefaultMediaNotificationProvider -import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.util.PlayerUtil.playbackFile @UnstableApi class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index 0452109646d1..572a522bd828 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -16,11 +16,14 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.MediaSession import androidx.media3.session.SessionToken -import com.nextcloud.client.player.media3.common.playbackFile -import com.nextcloud.client.player.media3.common.toMediaItem -import com.nextcloud.client.player.media3.common.indexOfFirst -import com.nextcloud.client.player.media3.common.setRepeatMode -import com.nextcloud.client.player.media3.common.updateMediaItems +import com.nextcloud.client.player.util.PlayerUtil.playbackFile +import com.nextcloud.client.player.util.PlayerUtil.readCurrentFiles +import com.nextcloud.client.player.util.PlayerUtil.readMediaIds +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackState +import com.nextcloud.client.player.util.PlayerUtil.toMediaItem +import com.nextcloud.client.player.util.PlayerUtil.indexOfFirst +import com.nextcloud.client.player.util.PlayerUtil.setRepeatMode +import com.nextcloud.client.player.util.PlayerUtil.updateMediaItems import com.nextcloud.client.player.media3.session.MediaSessionFactory import com.nextcloud.client.player.model.PlaybackSettings import com.nextcloud.client.player.model.file.PlaybackFile diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt index 99fe41b8ab0f..ba063c2eda3b 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt @@ -16,7 +16,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSessionService -import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.util.PlayerUtil.playbackFile import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.ui.PlayerActivity import dagger.android.AndroidInjection diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt deleted file mode 100644 index ab49b4fecec6..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3 - -import androidx.media3.common.Player -import com.nextcloud.client.player.media3.common.playbackFile -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.state.PlaybackItemMetadata -import com.nextcloud.client.player.model.state.PlaybackItemState -import com.nextcloud.client.player.model.state.PlaybackState -import com.nextcloud.client.player.model.state.PlayerState -import com.nextcloud.client.player.model.state.RepeatMode -import com.nextcloud.client.player.model.state.VideoSize - -fun Player.toPlaybackState(currentFiles: List): PlaybackState = PlaybackState( - currentFiles = currentFiles, - currentItemState = getCurrentItemState(), - repeatMode = mapRepeatMode(), - shuffle = shuffleModeEnabled -) - -fun Player.readMediaIds(): List = buildList { - for (i in 0 until mediaItemCount) { - add(getMediaItemAt(i).mediaId) - } -} - -fun Player.readCurrentFiles(): List = buildList { - for (i in 0 until mediaItemCount) { - val mediaItem = getMediaItemAt(i) - val playbackFile = mediaItem.mediaMetadata.playbackFile - playbackFile?.let(::add) - } -} - -private fun Player.getCurrentItemState(): PlaybackItemState? { - val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null - return PlaybackItemState( - file = currentFile, - playerState = mapPlayerState(), - metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null, - videoSize = mapVideoSize(), - currentTimeInMilliseconds = currentPosition, - maxTimeInMilliseconds = duration - ) -} - -private fun Player.mapPlayerState(): PlayerState = when (playbackState) { - Player.STATE_IDLE -> PlayerState.IDLE - Player.STATE_ENDED -> PlayerState.COMPLETED - Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED - else -> PlayerState.NONE -} - -private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata( - title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(), - artist = mediaMetadata.artist, - album = mediaMetadata.albumTitle, - genre = mediaMetadata.genre, - year = mediaMetadata.recordingYear, - description = mediaMetadata.description, - artworkData = mediaMetadata.artworkData, - artworkUri = mediaMetadata.artworkUri?.toString() -) - -private fun Player.mapVideoSize(): VideoSize? = videoSize - .takeIf { it.width > 0 && it.height > 0 } - ?.let { VideoSize(width = it.width, height = it.height) } - -private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) { - Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> RepeatMode.OFF -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt deleted file mode 100644 index 9196991d9809..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.common - -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import com.nextcloud.client.player.model.file.PlaybackFile - -fun PlaybackFile.toMediaItem(): MediaItem = MediaItem - .Builder() - .setMediaId(id) - .setUri(uri) - .setMediaMetadata(MediaMetadata.Builder().setExtras(this).build()) - .setMimeType(mimeType) - .build() diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt deleted file mode 100644 index 6efcec306644..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.common - -import android.os.Bundle -import androidx.media3.common.MediaMetadata -import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.util.getPlaybackFile -import com.nextcloud.client.player.util.putPlaybackFile - -private const val PLAYBACK_FILE_KEY = "playback_file" - -fun MediaMetadata.Builder.setExtras(playbackFile: PlaybackFile): MediaMetadata.Builder = setExtras( - Bundle().apply { - putPlaybackFile(PLAYBACK_FILE_KEY, playbackFile) - } -) - -val MediaMetadata.playbackFile: PlaybackFile? - get() = extras.getPlaybackFile(PLAYBACK_FILE_KEY) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt deleted file mode 100644 index 4680242237b2..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.media3.common - -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import com.nextcloud.client.player.model.state.RepeatMode - -fun Player.indexOfFirst(satisfies: (MediaItem) -> Boolean): Int { - for (index in 0..) { - val oldCurrentMediaItemIndex = currentMediaItemIndex - .takeIf { it >= 0 } - - val newCurrentMediaItemIndex = currentMediaItem - ?.mediaId - ?.let { currentMediaId -> newMediaItems.indexOfFirst { it.mediaId == currentMediaId } } - ?.takeIf { it >= 0 } - - if (oldCurrentMediaItemIndex != null && newCurrentMediaItemIndex != null) { - if (oldCurrentMediaItemIndex < mediaItemCount - 1) { - removeMediaItems(oldCurrentMediaItemIndex + 1, mediaItemCount) - } - if (newCurrentMediaItemIndex < newMediaItems.size - 1) { - val itemsToAdd = newMediaItems.subList(newCurrentMediaItemIndex + 1, newMediaItems.size) - addMediaItems(itemsToAdd) - } - if (oldCurrentMediaItemIndex > 0) { - removeMediaItems(0, oldCurrentMediaItemIndex) - } - if (newCurrentMediaItemIndex > 0) { - val itemsToAdd = newMediaItems.subList(0, newCurrentMediaItemIndex) - addMediaItems(0, itemsToAdd) - } - replaceMediaItem(newCurrentMediaItemIndex, newMediaItems[newCurrentMediaItemIndex]) - } else { - setMediaItems(newMediaItems) - } -} - -fun Player.setRepeatMode(mode: RepeatMode) { - repeatMode = when (mode) { - RepeatMode.SINGLE -> Player.REPEAT_MODE_ONE - RepeatMode.ALL -> Player.REPEAT_MODE_ALL - RepeatMode.OFF -> Player.REPEAT_MODE_OFF - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt index 95f74088c8b5..6a54455b05ad 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt @@ -12,7 +12,7 @@ import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec -import com.nextcloud.client.player.model.file.getRemoteFileId +import com.nextcloud.client.player.util.PlayerUtil.getRemoteFileId import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.files.StreamMediaFileOperation diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt index b3a6d281411b..f34d46b58b0f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt @@ -10,10 +10,10 @@ package com.nextcloud.client.player.media3.resumption import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession.MediaItemsWithStartPosition import com.nextcloud.client.player.media3.PlaybackModel -import com.nextcloud.client.player.media3.common.toMediaItem +import com.nextcloud.client.player.util.PlayerUtil.toMediaItem import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.file.PlaybackFilesRepository -import com.nextcloud.client.player.model.file.getPlaybackUri +import com.nextcloud.client.player.util.PlayerUtil.getPlaybackUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt index cd49ae45d214..3ece7169c72c 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -20,7 +20,7 @@ import androidx.media3.datasource.DataSourceBitmapLoader import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListeningExecutorService import com.google.common.util.concurrent.MoreExecutors -import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.util.PlayerUtil.playbackFile import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.owncloud.android.R diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt deleted file mode 100644 index 9993f89172c3..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model.file - -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.resources.shares.OCShare -import com.owncloud.android.utils.MimeTypeUtil -import java.io.File - -fun OCFile.toPlaybackFile() = PlaybackFile( - id = localId.toString(), - uri = getPlaybackUri().toString(), - name = fileName, - mimeType = mimeType, - contentLength = fileLength, - lastModified = modificationTimestamp, - isFavorite = isFavorite -) - -fun OCShare.toPlaybackFile() = PlaybackFile( - id = fileSource.toString(), - uri = getPlaybackUri().toString(), - name = path?.let { File(it).name } ?: "", - mimeType = getMimeType(), - contentLength = -1L, - lastModified = sharedDate * 1000L, - isFavorite = isFavorite -) - -private fun OCShare.getMimeType(): String = mimetype - ?.takeIf { it.isNotEmpty() } - ?: path?.let { MimeTypeUtil.getMimeTypeFromPath(it) } - ?: "" diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt deleted file mode 100644 index 562a2e3a2d53..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.model.file - -import android.net.Uri -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.resources.shares.OCShare - -const val REMOTE_FILE_SCHEME = "remoteFile" - -fun OCFile.getPlaybackUri(): Uri = getPlaybackUri(localId) - -fun OCShare.getPlaybackUri(): Uri = getPlaybackUri(fileSource) - -fun getPlaybackUri(fileId: Long): Uri = Uri.Builder() - .scheme(REMOTE_FILE_SCHEME) - .authority("") - .appendPath(fileId.toString()) - .build() - -fun Uri.getRemoteFileId(): Long? = scheme - ?.takeIf { it == REMOTE_FILE_SCHEME } - ?.let { pathSegments.firstOrNull()?.toLongOrNull() } diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt index e94a9d7e3be7..e942cc4d1b4c 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt @@ -10,7 +10,8 @@ package com.nextcloud.client.player.model.file import android.content.ContentUris import android.content.Context import android.net.Uri -import com.nextcloud.client.player.util.observeContentChanges +import com.nextcloud.client.player.util.PlayerUtil.observeContentChanges +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.MainApp import com.owncloud.android.datamodel.FileDataStorageManager diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 6a3fa237ca98..a8607c17a4a2 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -30,7 +30,7 @@ import com.nextcloud.client.di.ViewModelFactory import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.ui.audio.AudioPlayerView import com.nextcloud.client.player.ui.video.VideoPlayerView -import com.nextcloud.client.player.util.isPictureInPictureAllowed +import com.nextcloud.client.player.util.PlayerUtil.isPictureInPictureAllowed import com.nextcloud.ui.fileactions.FileAction import com.nextcloud.ui.fileactions.FileActionsBottomSheet import com.nextcloud.utils.extensions.getSerializableArgument diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt index d9912f12c756..44b1e05f3003 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt @@ -17,7 +17,7 @@ import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.file.PlaybackFilesComparator import com.nextcloud.client.player.model.file.PlaybackFilesRepository -import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.fragment.SearchType import kotlinx.coroutines.Job diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt index d70435d7d34d..7c6cb1803e17 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt @@ -13,7 +13,7 @@ import androidx.annotation.AttrRes import com.google.android.material.progressindicator.LinearProgressIndicator import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.nextcloud.client.player.model.state.PlaybackItemState import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.PlayerState diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt index a1c0dcb522e5..fab8e53ddd8e 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -18,8 +18,8 @@ import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.state.PlaybackItemMetadata import com.nextcloud.client.player.model.state.PlaybackState -import com.nextcloud.client.player.util.getPlaybackFile -import com.nextcloud.client.player.util.putPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.getPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.putPlaybackFile import com.owncloud.android.R import com.owncloud.android.databinding.PlayerAudioFileFragmentBinding import com.owncloud.android.utils.DisplayUtils diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt index 4484d3f4c893..57af46c555f2 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -19,7 +19,7 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.widget.ViewPager2 import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.util.rotate +import com.nextcloud.client.player.util.PlayerUtil.rotate import com.owncloud.android.R private const val NO_SHIFT = -1 diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index 4efe5ed1d20c..07b5960389b4 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -19,9 +19,9 @@ import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.VideoSize -import com.nextcloud.client.player.util.applyVideoSize -import com.nextcloud.client.player.util.getPlaybackFile -import com.nextcloud.client.player.util.putPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.applyVideoSize +import com.nextcloud.client.player.util.PlayerUtil.getPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.putPlaybackFile import com.owncloud.android.R import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding import dagger.android.support.AndroidSupportInjection diff --git a/app/src/main/java/com/nextcloud/client/player/util/Bundle.kt b/app/src/main/java/com/nextcloud/client/player/util/Bundle.kt deleted file mode 100644 index 06c7df3ddcbe..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/util/Bundle.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.util - -import android.os.Bundle -import com.nextcloud.client.player.model.file.PlaybackFile -import kotlinx.serialization.json.Json - -private val playbackJson = Json { ignoreUnknownKeys = true } - -fun Bundle.putPlaybackFile(key: String, playbackFile: PlaybackFile) { - putString(key, playbackJson.encodeToString(playbackFile)) -} - -fun Bundle?.getPlaybackFile(key: String): PlaybackFile? { - val encoded = this?.getString(key) ?: return null - return runCatching { playbackJson.decodeFromString(encoded) }.getOrNull() -} diff --git a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt deleted file mode 100644 index b61117bf5987..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.util - -import android.content.ContentResolver -import android.database.ContentObserver -import android.net.Uri -import android.os.Handler -import android.os.Looper -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow - -fun ContentResolver.observeContentChanges(uri: Uri, notifyForDescendants: Boolean) = callbackFlow { - val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - trySend(selfChange) - } - } - registerContentObserver(uri, notifyForDescendants, contentObserver) - awaitClose { unregisterContentObserver(contentObserver) } -} diff --git a/app/src/main/java/com/nextcloud/client/player/util/Context.kt b/app/src/main/java/com/nextcloud/client/player/util/Context.kt deleted file mode 100644 index 7a7080565b95..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/util/Context.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.util - -import android.app.AppOpsManager -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import android.os.Process - -fun Context.isPictureInPictureAllowed(): Boolean { - if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { - val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager - appOpsManager?.let { - val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) - } else { - @Suppress("DEPRECATION") - it.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) - } - return mode == AppOpsManager.MODE_ALLOWED - } - } - return false -} diff --git a/app/src/main/java/com/nextcloud/client/player/util/List.kt b/app/src/main/java/com/nextcloud/client/player/util/List.kt deleted file mode 100644 index 05c238c78a1d..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/util/List.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.util - -import java.util.Collections - -fun List.rotate(shift: Int): List { - val copy = ArrayList(this) - Collections.rotate(copy, shift) - return copy -} diff --git a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt new file mode 100644 index 000000000000..7a2c483f6c57 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt @@ -0,0 +1,300 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.util + +import android.app.AppOpsManager +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.database.ContentObserver +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.util.DisplayMetrics +import android.view.SurfaceView +import android.view.ViewGroup +import android.view.WindowManager +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemMetadata +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.model.state.VideoSize +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.utils.MimeTypeUtil +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.serialization.json.Json +import java.io.File +import java.util.Collections + +@Suppress("TooManyFunctions") +object PlayerUtil { + + const val REMOTE_FILE_SCHEME = "remoteFile" + + private const val PLAYBACK_FILE_KEY = "playback_file" + private const val NO_INDEX = -1 + private const val UNKNOWN_CONTENT_LENGTH = -1L + private const val SECOND_IN_MILLISECONDS = 1000L + + private val playbackJson = Json { ignoreUnknownKeys = true } + + fun PlaybackFile.toMediaItem(): MediaItem = MediaItem + .Builder() + .setMediaId(id) + .setUri(uri) + .setMediaMetadata(MediaMetadata.Builder().setExtras(this).build()) + .setMimeType(mimeType) + .build() + + fun MediaMetadata.Builder.setExtras(playbackFile: PlaybackFile): MediaMetadata.Builder = setExtras( + Bundle().apply { + putPlaybackFile(PLAYBACK_FILE_KEY, playbackFile) + } + ) + + val MediaMetadata.playbackFile: PlaybackFile? + get() = extras.getPlaybackFile(PLAYBACK_FILE_KEY) + + fun Player.indexOfFirst(satisfies: (MediaItem) -> Boolean): Int { + for (index in 0..) { + val oldCurrentMediaItemIndex = currentMediaItemIndex.takeIf { it >= 0 } + + val newCurrentMediaItemIndex = currentMediaItem + ?.mediaId + ?.let { currentMediaId -> newMediaItems.indexOfFirst { it.mediaId == currentMediaId } } + ?.takeIf { it >= 0 } + + if (oldCurrentMediaItemIndex != null && newCurrentMediaItemIndex != null) { + if (oldCurrentMediaItemIndex < mediaItemCount - 1) { + removeMediaItems(oldCurrentMediaItemIndex + 1, mediaItemCount) + } + if (newCurrentMediaItemIndex < newMediaItems.size - 1) { + val itemsToAdd = newMediaItems.subList(newCurrentMediaItemIndex + 1, newMediaItems.size) + addMediaItems(itemsToAdd) + } + if (oldCurrentMediaItemIndex > 0) { + removeMediaItems(0, oldCurrentMediaItemIndex) + } + if (newCurrentMediaItemIndex > 0) { + val itemsToAdd = newMediaItems.subList(0, newCurrentMediaItemIndex) + addMediaItems(0, itemsToAdd) + } + replaceMediaItem(newCurrentMediaItemIndex, newMediaItems[newCurrentMediaItemIndex]) + } else { + setMediaItems(newMediaItems) + } + } + + fun Player.setRepeatMode(mode: RepeatMode) { + repeatMode = when (mode) { + RepeatMode.SINGLE -> Player.REPEAT_MODE_ONE + RepeatMode.ALL -> Player.REPEAT_MODE_ALL + RepeatMode.OFF -> Player.REPEAT_MODE_OFF + } + } + + fun Player.toPlaybackState(currentFiles: List): PlaybackState = PlaybackState( + currentFiles = currentFiles, + currentItemState = getCurrentItemState(), + repeatMode = mapRepeatMode(), + shuffle = shuffleModeEnabled + ) + + fun Player.readMediaIds(): List = buildList { + for (i in 0 until mediaItemCount) { + add(getMediaItemAt(i).mediaId) + } + } + + fun Player.readCurrentFiles(): List = buildList { + for (i in 0 until mediaItemCount) { + getMediaItemAt(i).mediaMetadata.playbackFile?.let(::add) + } + } + + private fun Player.getCurrentItemState(): PlaybackItemState? { + val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null + return PlaybackItemState( + file = currentFile, + playerState = mapPlayerState(), + metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null, + videoSize = mapVideoSize(), + currentTimeInMilliseconds = currentPosition, + maxTimeInMilliseconds = duration + ) + } + + private fun Player.mapPlayerState(): PlayerState = when (playbackState) { + Player.STATE_IDLE -> PlayerState.IDLE + Player.STATE_ENDED -> PlayerState.COMPLETED + Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + else -> PlayerState.NONE + } + + private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata( + title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(), + artist = mediaMetadata.artist, + album = mediaMetadata.albumTitle, + genre = mediaMetadata.genre, + year = mediaMetadata.recordingYear, + description = mediaMetadata.description, + artworkData = mediaMetadata.artworkData, + artworkUri = mediaMetadata.artworkUri?.toString() + ) + + private fun Player.mapVideoSize(): VideoSize? = videoSize + .takeIf { it.width > 0 && it.height > 0 } + ?.let { VideoSize(width = it.width, height = it.height) } + + private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) { + Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> RepeatMode.OFF + } + + fun OCFile.toPlaybackFile() = PlaybackFile( + id = localId.toString(), + uri = getPlaybackUri().toString(), + name = fileName, + mimeType = mimeType, + contentLength = fileLength, + lastModified = modificationTimestamp, + isFavorite = isFavorite + ) + + fun OCShare.toPlaybackFile() = PlaybackFile( + id = fileSource.toString(), + uri = getPlaybackUri().toString(), + name = path?.let { File(it).name } ?: "", + mimeType = getMimeType(), + contentLength = UNKNOWN_CONTENT_LENGTH, + lastModified = sharedDate * SECOND_IN_MILLISECONDS, + isFavorite = isFavorite + ) + + private fun OCShare.getMimeType(): String = mimetype + ?.takeIf { it.isNotEmpty() } + ?: path?.let { MimeTypeUtil.getMimeTypeFromPath(it) } + ?: "" + + fun OCFile.getPlaybackUri(): Uri = getPlaybackUri(localId) + + fun OCShare.getPlaybackUri(): Uri = getPlaybackUri(fileSource) + + fun getPlaybackUri(fileId: Long): Uri = Uri.Builder() + .scheme(REMOTE_FILE_SCHEME) + .authority("") + .appendPath(fileId.toString()) + .build() + + fun Uri.getRemoteFileId(): Long? = scheme + ?.takeIf { it == REMOTE_FILE_SCHEME } + ?.let { pathSegments.firstOrNull()?.toLongOrNull() } + + fun Bundle.putPlaybackFile(key: String, playbackFile: PlaybackFile) { + putString(key, playbackJson.encodeToString(playbackFile)) + } + + fun Bundle?.getPlaybackFile(key: String): PlaybackFile? { + val encoded = this?.getString(key) ?: return null + return runCatching { playbackJson.decodeFromString(encoded) }.getOrNull() + } + + fun ContentResolver.observeContentChanges(uri: Uri, notifyForDescendants: Boolean) = callbackFlow { + val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + trySend(selfChange) + } + } + registerContentObserver(uri, notifyForDescendants, contentObserver) + awaitClose { unregisterContentObserver(contentObserver) } + } + + fun Context.isPictureInPictureAllowed(): Boolean { + val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager + if (appOpsManager == null || !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { + return false + } + + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOpsManager.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) + } else { + @Suppress("DEPRECATION") + appOpsManager.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) + } + return mode == AppOpsManager.MODE_ALLOWED + } + + fun Context.getDisplayWidth(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + windowManager.currentWindowMetrics.bounds.width() + } else { + getDisplayMetrics().widthPixels + } + + fun Context.getDisplayHeight(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + windowManager.currentWindowMetrics.bounds.height() + } else { + getDisplayMetrics().heightPixels + } + + private val Context.windowManager: WindowManager + get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager + + @Suppress("DEPRECATION") + private fun Context.getDisplayMetrics(): DisplayMetrics { + val displayMetrics = DisplayMetrics() + windowManager.defaultDisplay.getRealMetrics(displayMetrics) + return displayMetrics + } + + fun List.rotate(shift: Int): List { + val copy = ArrayList(this) + Collections.rotate(copy, shift) + return copy + } + + /** + * Letterboxes the surface inside its container so the video keeps its aspect ratio. + */ + fun SurfaceView.applyVideoSize(videoSize: VideoSize) { + val screenWidth = context.getDisplayWidth() + val screenHeight = context.getDisplayHeight() + val screenProportion = screenWidth.toFloat() / screenHeight.toFloat() + val videoProportion = videoSize.width.toFloat() / videoSize.height.toFloat() + + layoutParams = layoutParams.apply { + if (screenProportion < videoProportion) { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = (screenWidth.toFloat() / videoProportion).toInt() + } else { + width = (videoProportion * screenHeight.toFloat()).toInt() + height = ViewGroup.LayoutParams.MATCH_PARENT + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt deleted file mode 100644 index 9681bb72d0b4..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.util - -import android.content.Context -import android.os.Build -import android.util.DisplayMetrics -import android.view.WindowManager - -fun Context.getDisplayWidth(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - windowManager.currentWindowMetrics.bounds.width() -} else { - getDisplayMetrics().widthPixels -} - -fun Context.getDisplayHeight(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - windowManager.currentWindowMetrics.bounds.height() -} else { - getDisplayMetrics().heightPixels -} - -private val Context.windowManager: WindowManager - get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager - -@Suppress("DEPRECATION") -private fun Context.getDisplayMetrics(): DisplayMetrics { - val displayMetrics = DisplayMetrics() - windowManager.defaultDisplay.getRealMetrics(displayMetrics) - return displayMetrics -} diff --git a/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt b/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt deleted file mode 100644 index dcbfd3555790..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.util - -import android.view.SurfaceView -import android.view.ViewGroup -import com.nextcloud.client.player.model.state.VideoSize - -/** - * Letterboxes the surface inside its container so the video keeps its aspect ratio. - */ -fun SurfaceView.applyVideoSize(videoSize: VideoSize) { - val screenWidth = context.getDisplayWidth() - val screenHeight = context.getDisplayHeight() - val screenProportion = screenWidth.toFloat() / screenHeight.toFloat() - val videoProportion = videoSize.width.toFloat() / videoSize.height.toFloat() - - layoutParams = layoutParams.apply { - if (screenProportion < videoProportion) { - width = ViewGroup.LayoutParams.MATCH_PARENT - height = (screenWidth.toFloat() / videoProportion).toInt() - } else { - width = (videoProportion * screenHeight.toFloat()).toInt() - height = ViewGroup.LayoutParams.MATCH_PARENT - } - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index 9a681f5d26d7..e6b2e95cfa99 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -19,15 +19,15 @@ import androidx.lifecycle.lifecycleScope import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.PlayerState import com.nextcloud.client.player.model.state.VideoSize import com.nextcloud.client.player.ui.PlayerActivity import com.nextcloud.client.player.ui.PlayerLauncher -import com.nextcloud.client.player.util.applyVideoSize -import com.nextcloud.client.player.util.isPictureInPictureAllowed +import com.nextcloud.client.player.util.PlayerUtil.applyVideoSize +import com.nextcloud.client.player.util.PlayerUtil.isPictureInPictureAllowed import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R From 3eb1c79a3e530f2a6becb9f6fcdff293060c58fd Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 10:35:29 +0200 Subject: [PATCH 45/69] wip Signed-off-by: alperozturk96 --- .../client/player/ui/PlayerActivity.kt | 34 +++++++++--- .../nextcloud/client/player/ui/PlayerView.kt | 7 +++ .../ui/control/MultipleClickListener.kt | 41 -------------- .../player/ui/control/PlayerControlView.kt | 23 +++----- .../client/player/ui/pager/PlayerPager.kt | 24 ++++++-- .../player/ui/video/VideoFileFragment.kt | 22 ++++++-- .../client/player/util/PlayerUtil.kt | 55 +++++++------------ .../ui/preview/PreviewImageActivity.kt | 7 +++ .../ui/preview/PreviewMediaPagerAdapter.kt | 2 + .../ui/preview/PreviewPlaybackFragment.kt | 17 ++++-- 10 files changed, 117 insertions(+), 115 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index a8607c17a4a2..e027764b7094 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -47,6 +47,8 @@ import javax.inject.Inject private const val PIP_ASPECT_RATIO_WIDTH = 16 private const val PIP_ASPECT_RATIO_HEIGHT = 9 +private const val MIN_PIP_ASPECT_RATIO = 0.42f +private const val MAX_PIP_ASPECT_RATIO = 2.39f class PlayerActivity : FileActivity(), @@ -82,7 +84,7 @@ class PlayerActivity : private lateinit var playerView: PlayerView - private val pipAspectRatio = Rational(PIP_ASPECT_RATIO_WIDTH, PIP_ASPECT_RATIO_HEIGHT) + private val defaultPipAspectRatio = Rational(PIP_ASPECT_RATIO_WIDTH, PIP_ASPECT_RATIO_HEIGHT) private var onBackPressedCallback: OnBackPressedCallback? = null @@ -139,6 +141,7 @@ class PlayerActivity : private fun recreatePlayerView() { playerView.onStop() + playerView.release() createPlayerView() playerView.onStart() } @@ -229,19 +232,32 @@ class PlayerActivity : enterPictureInPictureMode(params) } - private fun createPictureInPictureParams(): PictureInPictureParams = PictureInPictureParams.Builder().let { - it.setAspectRatio(pipAspectRatio) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - it.setAutoEnterEnabled(true) + private fun createPictureInPictureParams(): PictureInPictureParams { + val aspectRatio = getPictureInPictureAspectRatio() + return PictureInPictureParams.Builder().let { + it.setAspectRatio(aspectRatio) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + it.setAutoEnterEnabled(true) + } + it.setSourceRectHint(getSourceRectHint(aspectRatio)) + it.build() + } + } + + private fun getPictureInPictureAspectRatio(): Rational { + val videoSize = playbackModel.state?.currentItemState?.videoSize ?: return defaultPipAspectRatio + val ratio = videoSize.width.toFloat() / videoSize.height.toFloat() + return if (ratio < MIN_PIP_ASPECT_RATIO || ratio > MAX_PIP_ASPECT_RATIO) { + defaultPipAspectRatio + } else { + Rational(videoSize.width, videoSize.height) } - it.setSourceRectHint(getSourceRectHint()) - it.build() } - private fun getSourceRectHint(): Rect { + private fun getSourceRectHint(aspectRatio: Rational): Rect { val containerRect = Rect() playerView.getGlobalVisibleRect(containerRect) - val sourceHeightHint = (containerRect.width() / pipAspectRatio.toFloat()).toInt() + val sourceHeightHint = (containerRect.width() / aspectRatio.toFloat()).toInt() return Rect( containerRect.left, containerRect.top + (containerRect.height() - sourceHeightHint) / 2, diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index f074dc8c3d99..b930c1ab8153 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -74,6 +74,8 @@ abstract class PlayerView @JvmOverloads constructor( (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) playerPager.initialize(activity.supportFragmentManager, activity.lifecycle, createFragment) playerPager.onItemSelected = { playbackModel.switchToFile(it) } + playerControlView.onNextClick = { playerPager.showNext() } + playerControlView.onPreviousClick = { playerPager.showPrevious() } findViewById(R.id.back).setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() } } } @@ -97,6 +99,11 @@ abstract class PlayerView @JvmOverloads constructor( playerControlView.onStop() } + @CallSuper + open fun release() { + playerPager.release() + } + override fun onPlaybackUpdate(state: PlaybackState) { render(state) } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt deleted file mode 100644 index 39957620b263..000000000000 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 STRATO GmbH. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.player.ui.control - -import android.os.Handler -import android.os.Looper -import android.view.View - -private const val TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS = 250L -private const val SINGLE_CLICK_COUNT = 1 - -class MultipleClickListener(private val onSingleClick: () -> Unit, private val onDoubleClick: () -> Unit) : - View.OnClickListener { - - private val handler = Handler(Looper.getMainLooper()) - private var clicksCount: Int? = null - - override fun onClick(view: View?) { - val pendingClicksCount = clicksCount - if (pendingClicksCount != null) { - clicksCount = pendingClicksCount + 1 - return - } - - clicksCount = SINGLE_CLICK_COUNT - handler.postDelayed({ - val count = clicksCount ?: SINGLE_CLICK_COUNT - clicksCount = null - if (count == SINGLE_CLICK_COUNT) { - onSingleClick() - } else { - onDoubleClick() - } - }, TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS) - } -} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index 86baf378b418..01c0d3cc4459 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -69,6 +69,10 @@ class PlayerControlView @JvmOverloads constructor( val binding = PlayerControlViewBinding.inflate(LayoutInflater.from(context), this, true) + var onNextClick: () -> Unit = { playbackModel.playNext() } + + var onPreviousClick: () -> Unit = { playbackModel.playPrevious() } + init { if (!isInEditMode) { (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) @@ -131,22 +135,9 @@ class PlayerControlView @JvmOverloads constructor( playbackModel.setShuffle(binding.ivRandom.tag == TAG_CLICK_COMMAND_SHUFFLE) } - binding.ivNext.setOnClickListener { playbackModel.playNext() } - - binding.ivPrevious.setOnClickListener( - MultipleClickListener( - onSingleClick = { - playbackModel.state?.currentItemState?.let { state -> - if (state.playerState == PlayerState.PAUSED || state.playerState == PlayerState.PLAYING) { - playbackModel.seekToPosition(0L) - } else { - playbackModel.playPrevious() - } - } - }, - onDoubleClick = { playbackModel.playPrevious() } - ) - ) + binding.ivNext.setOnClickListener { onNextClick() } + + binding.ivPrevious.setOnClickListener { onPreviousClick() } binding.progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt index 57af46c555f2..94948c2f2586 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -25,7 +25,6 @@ import com.owncloud.android.R private const val NO_SHIFT = -1 private const val NO_POSITION = -1 private const val FIRST_ENTITY_POSITION = 1 - private const val OFFSCREEN_PAGE_LIMIT = 1 class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : @@ -79,6 +78,11 @@ class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSe restoredShift = restoredState.shiftedPosition } + fun release() { + viewPager.unregisterOnPageChangeCallback(onPageChangeCallback) + viewPager.adapter = null + } + fun getItems(): List = adapter.getEntities() fun setItems(items: List) { @@ -86,13 +90,21 @@ class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSe notifyDataSetChangedWithoutCallingListener() } + fun showNext() { + viewPager.setCurrentItem(viewPager.currentItem + 1, true) + } + + fun showPrevious() { + viewPager.setCurrentItem(viewPager.currentItem - 1, true) + } + fun setCurrentItem(item: PlaybackFile) { val position = adapter.getEntityIndex(item) - if (position != NO_POSITION && viewPager.currentItem != position) { - viewPager.unregisterOnPageChangeCallback(onPageChangeCallback) - viewPager.setCurrentItem(position, true) - viewPager.registerOnPageChangeCallback(onPageChangeCallback) - } + if (position == NO_POSITION || viewPager.currentItem == position) return + + viewPager.unregisterOnPageChangeCallback(onPageChangeCallback) + viewPager.setCurrentItem(position, viewPager.isLaidOut) + viewPager.registerOnPageChangeCallback(onPageChangeCallback) } private fun isStubPosition(position: Int): Boolean = position == 0 || position >= lastStubPosition diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index 07b5960389b4..fa864f7126d6 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -7,8 +7,10 @@ package com.nextcloud.client.player.ui.video +import android.graphics.Rect import android.os.Bundle import android.view.LayoutInflater +import android.view.SurfaceView import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible @@ -51,8 +53,6 @@ class VideoFileFragment : private var _binding: PlayerVideoFileFragmentBinding? = null private val binding get() = checkNotNull(_binding) { "Binding accessed outside of the view lifecycle" } - private var previousVideoSize: VideoSize? = null - private val file by lazy { requireNotNull(arguments.getPlaybackFile(ARGUMENT_FILE)) { "VideoFileFragment requires a $ARGUMENT_FILE argument" @@ -70,6 +70,9 @@ class VideoFileFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) loadFileThumbnail() + binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + render(playerModel.state) + } } override fun onStart() { @@ -78,6 +81,11 @@ class VideoFileFragment : playerModel.addListener(this) } + override fun onResume() { + super.onResume() + render(playerModel.state) + } + override fun onStop() { playerModel.removeListener(this) super.onStop() @@ -100,6 +108,7 @@ class VideoFileFragment : } private fun render(state: PlaybackState?) { + val binding = _binding ?: return val currentItemState = state?.currentItemState if (currentItemState?.file == file) { @@ -114,13 +123,16 @@ class VideoFileFragment : } private fun showVideo(videoSize: VideoSize?) { - playerModel.setVideoSurfaceView(binding.surfaceView) + if (binding.surfaceView.ownsPlayback()) { + playerModel.setVideoSurfaceView(binding.surfaceView) + } binding.surfaceView.isVisible = true binding.surfaceView.alpha = if (videoSize == null) SURFACE_ALPHA_HIDDEN else SURFACE_ALPHA_VISIBLE - if (videoSize == null || previousVideoSize == videoSize) return + if (videoSize == null) return - previousVideoSize = videoSize binding.surfaceView.applyVideoSize(videoSize) } + + private fun SurfaceView.ownsPlayback(): Boolean = isResumed || getGlobalVisibleRect(Rect()) } diff --git a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt index 7a2c483f6c57..d35356e1749e 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt @@ -19,10 +19,9 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.Process -import android.util.DisplayMetrics import android.view.SurfaceView +import android.view.View import android.view.ViewGroup -import android.view.WindowManager import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player @@ -250,28 +249,6 @@ object PlayerUtil { return mode == AppOpsManager.MODE_ALLOWED } - fun Context.getDisplayWidth(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - windowManager.currentWindowMetrics.bounds.width() - } else { - getDisplayMetrics().widthPixels - } - - fun Context.getDisplayHeight(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - windowManager.currentWindowMetrics.bounds.height() - } else { - getDisplayMetrics().heightPixels - } - - private val Context.windowManager: WindowManager - get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager - - @Suppress("DEPRECATION") - private fun Context.getDisplayMetrics(): DisplayMetrics { - val displayMetrics = DisplayMetrics() - windowManager.defaultDisplay.getRealMetrics(displayMetrics) - return displayMetrics - } - fun List.rotate(shift: Int): List { val copy = ArrayList(this) Collections.rotate(copy, shift) @@ -282,19 +259,29 @@ object PlayerUtil { * Letterboxes the surface inside its container so the video keeps its aspect ratio. */ fun SurfaceView.applyVideoSize(videoSize: VideoSize) { - val screenWidth = context.getDisplayWidth() - val screenHeight = context.getDisplayHeight() - val screenProportion = screenWidth.toFloat() / screenHeight.toFloat() + val container = parent as? View + val containerWidth = container?.width ?: 0 + val containerHeight = container?.height ?: 0 + if (containerWidth <= 0 || containerHeight <= 0) return + + val containerProportion = containerWidth.toFloat() / containerHeight.toFloat() val videoProportion = videoSize.width.toFloat() / videoSize.height.toFloat() + val targetWidth: Int + val targetHeight: Int + if (containerProportion < videoProportion) { + targetWidth = ViewGroup.LayoutParams.MATCH_PARENT + targetHeight = (containerWidth.toFloat() / videoProportion).toInt() + } else { + targetWidth = (videoProportion * containerHeight.toFloat()).toInt() + targetHeight = ViewGroup.LayoutParams.MATCH_PARENT + } + + if (layoutParams.width == targetWidth && layoutParams.height == targetHeight) return + layoutParams = layoutParams.apply { - if (screenProportion < videoProportion) { - width = ViewGroup.LayoutParams.MATCH_PARENT - height = (screenWidth.toFloat() / videoProportion).toInt() - } else { - width = (videoProportion * screenHeight.toFloat()).toInt() - height = ViewGroup.LayoutParams.MATCH_PARENT - } + width = targetWidth + height = targetHeight } } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 282b54e8ef9c..2590835a8b8d 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -448,6 +448,13 @@ class PreviewImageActivity : FileDownloadHelper.instance().downloadFileIfNotStartedBefore(user, file) } + fun showFilePage(localId: Long) { + val position = previewMediaPagerAdapter?.getPositionByLocalId(localId) ?: return + if (position < 0 || viewPager?.currentItem == position) return + + viewPager?.setCurrentItem(position, true) + } + /** * This method will be invoked when a new page becomes selected. Animation is not necessarily * complete. diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index 77ffe7168d06..b32e008764fd 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -207,6 +207,8 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { fun getFilePosition(file: OCFile): Int = mediaFiles.indexOf(file) + fun getPositionByLocalId(localId: Long): Int = mediaFiles.indexOfFirst { it.localId == localId } + fun updateFile(position: Int, file: OCFile) { if (position < 0 || position >= mediaFiles.size) { return diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index e6b2e95cfa99..d1a31dbb2eb6 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -52,6 +52,8 @@ class PreviewPlaybackFragment : private const val ARGUMENT_FILE = "ARGUMENT_FILE" private const val ARGUMENT_SEARCH_TYPE = "ARGUMENT_SEARCH_TYPE" private const val ARGUMENT_AUTOPLAY = "ARGUMENT_AUTOPLAY" + private const val SURFACE_ALPHA_VISIBLE = 1f + private const val SURFACE_ALPHA_HIDDEN = 0f fun newInstance(file: OCFile, searchType: SearchType?, autoplay: Boolean = false) = PreviewPlaybackFragment().apply { @@ -77,7 +79,6 @@ class PreviewPlaybackFragment : private lateinit var playbackFile: PlaybackFile private var searchType: SearchType? = null private var autoplay: Boolean = false - private var previousVideoSize: VideoSize? = null private var pictureInPictureCallback: OnBackPressedCallback? = null @@ -95,6 +96,9 @@ class PreviewPlaybackFragment : binding = PreviewPlaybackFragmentBinding.inflate(inflater, container, false) loadThumbnail() registerPictureInPictureOnBack() + binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (isResumed) render(playbackModel.state) + } return binding.root } @@ -173,18 +177,23 @@ class PreviewPlaybackFragment : private fun render(state: PlaybackState?) { if (!isCurrentItem(state)) { binding.surfaceView.visibility = View.GONE + showPageOfCurrentItem(state) return } showVideo(state?.currentItemState?.videoSize) } + private fun showPageOfCurrentItem(state: PlaybackState?) { + val localId = state?.currentItemState?.file?.id?.toLongOrNull() ?: return + (activity as? PreviewImageActivity)?.showFilePage(localId) + } + private fun showVideo(videoSize: VideoSize?) { playbackModel.setVideoSurfaceView(binding.surfaceView) binding.surfaceView.visibility = View.VISIBLE - binding.surfaceView.alpha = if (videoSize != null) 1f else 0f + binding.surfaceView.alpha = if (videoSize != null) SURFACE_ALPHA_VISIBLE else SURFACE_ALPHA_HIDDEN - if (videoSize != null && previousVideoSize != videoSize) { - previousVideoSize = videoSize + if (videoSize != null) { binding.surfaceView.applyVideoSize(videoSize) } } From 4b179132716a00cda6a3ed4095c3902b5142f354 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 10:45:56 +0200 Subject: [PATCH 46/69] wip Signed-off-by: alperozturk96 --- .../ui/fragment/albums/AlbumItemsFragment.kt | 8 +++-- app/src/main/res/values/dims.xml | 28 +++++++++++++++ app/src/main/res/values/ids.xml | 1 + app/src/main/res/values/strings.xml | 35 ++++++++----------- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt index 604b1c84c8ac..cf68de86e867 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -88,7 +88,6 @@ import com.owncloud.android.ui.fragment.helper.ColumnCount import com.owncloud.android.ui.helpers.UriUploader import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface import com.owncloud.android.ui.preview.PreviewImageFragment -import com.owncloud.android.ui.preview.PreviewMediaActivity.Companion.canBePreviewed import com.owncloud.android.utils.ClipboardUtil import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.ErrorMessageAdapter @@ -562,17 +561,20 @@ class AlbumItemsFragment : activity.startImagePreview(file, !file.isDown, VirtualFolderType.ALBUM) file.isDown && canBePreviewed(file) -> - activity.startMediaPreview(file, 0, true, true, false, true) + activity.startMediaPreview(file, showPreview = true, streamMedia = false) file.isDown -> containerActivity?.fileOperationsHelper?.openFile(file) canBePreviewed(file) && !file.isEncrypted -> - activity.startMediaPreview(file, 0, true, true, true, true) + activity.startMediaPreview(file, showPreview = true, streamMedia = true) else -> Log_OC.d(TAG, "Couldn't handle item click") } } + fun canBePreviewed(file: OCFile?): Boolean = + file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) + override fun onLongItemClicked(file: OCFile): Boolean { if (selectionMode?.isActionModeActive == true) { toggleItemToCheckedList(file) diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 5e8e56a0f69a..723a3ecf9e62 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -160,4 +160,32 @@ 18dp 24dp 4dp + + + 56dp + 21sp + 24dp + 4dp + 12dp + 16dp + 16dp + 16sp + 8dp + 12sp + 32dp + 350dp + 96dp + 24dp + 8dp + 32dp + 2dp + 4dp + 12dp + 24dp + 1dp + 12dp + 15sp + 16dp + 8dp + 48dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index f626f7d2929e..3eed0d867c81 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -42,4 +42,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62321a715267..c1589ad6f91d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -431,18 +431,6 @@ Passcode deleted Passcode stored - %1$s music player - %1$s (playing) - Unsupported media codec - Could not read the media file - The media file has incorrect encoding - Attempt to play file timed out - The media file cannot be streamed - The built-in media player is unable to play the media file - Rewind button - Play or pause button - Fast forward button - No network connection Secure connection unavailable. Connection established @@ -512,12 +500,6 @@ - The URL does not match the hostname in the certificate Do you want to trust this certificate anyway? Could not save certificate - Seek forward - Seek backward - Play - Pause - Playing media - Details Hide Issued to: @@ -1120,7 +1102,6 @@ Delete permanently Stream with… Internal streaming not possible - Please download media instead or use external app. Folder already exists Notification icon Create @@ -1333,8 +1314,6 @@ 4 hours This week Media - stop - toggle Choose which file to keep! Data storage folder does not exist! This might be due to a backup restore on another device. Falling back to default. Please check settings to adjust data storage folder. @@ -1645,4 +1624,18 @@ Share link to album Stop sharing Everyone can only view + + + Export started (%d file) + Exports started (%d files) + + Source not found + Close + Modified: %s + %1$s • %2$s + Repeat button + Previous button + Play/Pause button + Next button + Random button From 46d231fa9bc14d2533c6ce8759887f9c4becbea3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 11:42:21 +0200 Subject: [PATCH 47/69] wip Signed-off-by: alperozturk96 --- app/src/main/AndroidManifest.xml | 2 + .../client/player/ui/MediaNavigator.kt | 19 ++++ .../client/player/ui/PlayerActivity.kt | 104 ++---------------- .../nextcloud/client/player/ui/PlayerView.kt | 3 +- .../client/player/ui/VideoPictureInPicture.kt | 71 ++++++++++++ .../player/ui/control/PlayerControlView.kt | 17 ++- .../client/player/ui/pager/PlayerPager.kt | 12 +- .../player/ui/video/VideoFileFragment.kt | 7 +- .../client/player/util/PlayerUtil.kt | 5 + .../ui/fragment/albums/AlbumItemsFragment.kt | 3 + .../ui/preview/PreviewImageActivity.kt | 71 +++++++++++- .../ui/preview/PreviewMediaPagerAdapter.kt | 1 + .../ui/preview/PreviewPlaybackFragment.kt | 74 +++++++++---- 13 files changed, 248 insertions(+), 141 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/MediaNavigator.kt create mode 100644 app/src/main/java/com/nextcloud/client/player/ui/VideoPictureInPicture.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9d11d5f81f91..f005e87a181c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -384,7 +384,9 @@ android:theme="@style/PreferenceTheme" /> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +interface MediaNavigator { + + val hasNext: Boolean + + val hasPrevious: Boolean + + fun showNext() + + fun showPrevious() +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index e027764b7094..8391ceddadc6 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -7,15 +7,11 @@ package com.nextcloud.client.player.ui -import android.app.PictureInPictureParams import android.content.Context import android.content.Intent import android.content.res.Configuration -import android.graphics.Rect import android.media.AudioManager -import android.os.Build import android.os.Bundle -import android.util.Rational import android.view.View import androidx.activity.OnBackPressedCallback import androidx.activity.addCallback @@ -30,7 +26,6 @@ import com.nextcloud.client.di.ViewModelFactory import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.ui.audio.AudioPlayerView import com.nextcloud.client.player.ui.video.VideoPlayerView -import com.nextcloud.client.player.util.PlayerUtil.isPictureInPictureAllowed import com.nextcloud.ui.fileactions.FileAction import com.nextcloud.ui.fileactions.FileActionsBottomSheet import com.nextcloud.utils.extensions.getSerializableArgument @@ -45,34 +40,18 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject -private const val PIP_ASPECT_RATIO_WIDTH = 16 -private const val PIP_ASPECT_RATIO_HEIGHT = 9 -private const val MIN_PIP_ASPECT_RATIO = 0.42f -private const val MAX_PIP_ASPECT_RATIO = 2.39f - class PlayerActivity : FileActivity(), Injectable { companion object { private const val PLAYBACK_FILE_TYPE: String = "PLAYBACK_FILE_TYPE" - private const val ENTER_PICTURE_IN_PICTURE: String = "ENTER_PICTURE_IN_PICTURE" - private const val RESUME_PLAYBACK: String = "RESUME_PLAYBACK" fun createIntent(context: Context, playbackFileType: PlaybackFileType): Intent = Intent(context, PlayerActivity::class.java).apply { putExtra(PLAYBACK_FILE_TYPE, playbackFileType) addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) } - - fun createPictureInPictureIntent( - context: Context, - playbackFileType: PlaybackFileType, - resumePlayback: Boolean - ): Intent = createIntent(context, playbackFileType).apply { - putExtra(ENTER_PICTURE_IN_PICTURE, true) - putExtra(RESUME_PLAYBACK, resumePlayback) - } } @Inject @@ -84,22 +63,18 @@ class PlayerActivity : private lateinit var playerView: PlayerView - private val defaultPipAspectRatio = Rational(PIP_ASPECT_RATIO_WIDTH, PIP_ASPECT_RATIO_HEIGHT) + private val pictureInPicture by lazy { VideoPictureInPicture(this, playbackModel, autoEnter = true) } private var onBackPressedCallback: OnBackPressedCallback? = null private var keepPlaybackAliveOnFinish = false - private var enterPictureInPictureOnResume = false - private var resumePlaybackOnResume = false - override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, windowInsets -> windowInsets } playbackFileType = intent.getPlaybackFileType() - readPictureInPictureRequest(intent) createPlayerView() viewModel.eventFlow @@ -108,14 +83,12 @@ class PlayerActivity : .launchIn(lifecycleScope) onBackPressedCallback = onBackPressedDispatcher.addCallback(this) { - val isVideoPlayback = playbackFileType == PlaybackFileType.VIDEO - - if (isPictureInPictureAllowed() && isVideoPlayback) { - switchToPictureInPictureMode() - } else { - file = file?.parentId?.let { storageManager.getFileById(it) } - finish() + if (canUsePictureInPictureMode() && pictureInPicture.enter(playerView)) { + return@addCallback } + + file = file?.parentId?.let { storageManager.getFileById(it) } + finish() } volumeControlStream = AudioManager.STREAM_MUSIC @@ -124,9 +97,7 @@ class PlayerActivity : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) playbackFileType = intent.getPlaybackFileType() - readPictureInPictureRequest(intent) recreatePlayerView() - onBackPressedCallback?.isEnabled = canUsePictureInPictureMode() } private fun createPlayerView() { @@ -155,26 +126,6 @@ class PlayerActivity : playerView.onStart() } - override fun onResume() { - super.onResume() - if (!enterPictureInPictureOnResume) { - return - } - enterPictureInPictureOnResume = false - - if (resumePlaybackOnResume) { - playbackModel.play() - } - if (canUsePictureInPictureMode()) { - switchToPictureInPictureMode() - } - } - - private fun readPictureInPictureRequest(intent: Intent) { - enterPictureInPictureOnResume = intent.getBooleanExtra(ENTER_PICTURE_IN_PICTURE, false) - resumePlaybackOnResume = intent.getBooleanExtra(RESUME_PLAYBACK, false) - } - override fun onStop() { super.onStop() playerView.onStop() @@ -203,7 +154,7 @@ class PlayerActivity : override fun onUserLeaveHint() { super.onUserLeaveHint() if (canUsePictureInPictureMode()) { - switchToPictureInPictureMode() + pictureInPicture.enter(playerView) } } @@ -225,46 +176,7 @@ class PlayerActivity : } private fun canUsePictureInPictureMode(): Boolean = - playbackFileType == PlaybackFileType.VIDEO && isPictureInPictureAllowed() - - private fun switchToPictureInPictureMode() { - val params = createPictureInPictureParams() - enterPictureInPictureMode(params) - } - - private fun createPictureInPictureParams(): PictureInPictureParams { - val aspectRatio = getPictureInPictureAspectRatio() - return PictureInPictureParams.Builder().let { - it.setAspectRatio(aspectRatio) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - it.setAutoEnterEnabled(true) - } - it.setSourceRectHint(getSourceRectHint(aspectRatio)) - it.build() - } - } - - private fun getPictureInPictureAspectRatio(): Rational { - val videoSize = playbackModel.state?.currentItemState?.videoSize ?: return defaultPipAspectRatio - val ratio = videoSize.width.toFloat() / videoSize.height.toFloat() - return if (ratio < MIN_PIP_ASPECT_RATIO || ratio > MAX_PIP_ASPECT_RATIO) { - defaultPipAspectRatio - } else { - Rational(videoSize.width, videoSize.height) - } - } - - private fun getSourceRectHint(aspectRatio: Rational): Rect { - val containerRect = Rect() - playerView.getGlobalVisibleRect(containerRect) - val sourceHeightHint = (containerRect.width() / aspectRatio.toFloat()).toInt() - return Rect( - containerRect.left, - containerRect.top + (containerRect.height() - sourceHeightHint) / 2, - containerRect.right, - containerRect.top + (containerRect.height() + sourceHeightHint) / 2 - ) - } + playbackFileType == PlaybackFileType.VIDEO && pictureInPicture.isAllowed private fun handleEvent(event: PlayerScreenEvent) { when (event) { diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt index b930c1ab8153..7dc7ca7e18fa 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -74,8 +74,7 @@ abstract class PlayerView @JvmOverloads constructor( (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) playerPager.initialize(activity.supportFragmentManager, activity.lifecycle, createFragment) playerPager.onItemSelected = { playbackModel.switchToFile(it) } - playerControlView.onNextClick = { playerPager.showNext() } - playerControlView.onPreviousClick = { playerPager.showPrevious() } + playerControlView.navigator = playerPager findViewById(R.id.back).setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() } } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/VideoPictureInPicture.kt b/app/src/main/java/com/nextcloud/client/player/ui/VideoPictureInPicture.kt new file mode 100644 index 000000000000..0946de7a6b19 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/VideoPictureInPicture.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +import android.app.Activity +import android.app.PictureInPictureParams +import android.graphics.Rect +import android.os.Build +import android.util.Rational +import android.view.View +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.model.state.VideoSize +import com.nextcloud.client.player.util.PlayerUtil.isPictureInPictureAllowed + +private const val ASPECT_RATIO_WIDTH = 16 +private const val ASPECT_RATIO_HEIGHT = 9 +private const val MIN_ASPECT_RATIO = 0.42f +private const val MAX_ASPECT_RATIO = 2.39f + +class VideoPictureInPicture( + private val activity: Activity, + private val playbackModel: PlaybackModel, + private val autoEnter: Boolean +) { + + private val defaultAspectRatio = Rational(ASPECT_RATIO_WIDTH, ASPECT_RATIO_HEIGHT) + + val isAllowed: Boolean get() = activity.isPictureInPictureAllowed() + + fun enter(sourceView: View): Boolean = isAllowed && activity.enterPictureInPictureMode(createParams(sourceView)) + + private fun createParams(sourceView: View): PictureInPictureParams { + val aspectRatio = getAspectRatio(playbackModel.state?.currentItemState?.videoSize) + return PictureInPictureParams.Builder().let { + it.setAspectRatio(aspectRatio) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + it.setAutoEnterEnabled(autoEnter) + } + it.setSourceRectHint(getSourceRectHint(sourceView, aspectRatio)) + it.build() + } + } + + private fun getAspectRatio(videoSize: VideoSize?): Rational { + if (videoSize == null) return defaultAspectRatio + + val ratio = videoSize.width.toFloat() / videoSize.height.toFloat() + return if (ratio < MIN_ASPECT_RATIO || ratio > MAX_ASPECT_RATIO) { + defaultAspectRatio + } else { + Rational(videoSize.width, videoSize.height) + } + } + + private fun getSourceRectHint(sourceView: View, aspectRatio: Rational): Rect { + val sourceRect = Rect() + sourceView.getGlobalVisibleRect(sourceRect) + val heightHint = (sourceRect.width() / aspectRatio.toFloat()).toInt() + return Rect( + sourceRect.left, + sourceRect.top + (sourceRect.height() - heightHint) / 2, + sourceRect.right, + sourceRect.top + (sourceRect.height() + heightHint) / 2 + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index 01c0d3cc4459..81c0129cd702 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -23,6 +23,7 @@ import com.nextcloud.client.player.model.state.PlaybackItemState import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.PlayerState import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.ui.MediaNavigator import com.owncloud.android.R import com.owncloud.android.databinding.PlayerControlViewBinding import dagger.android.HasAndroidInjector @@ -69,9 +70,7 @@ class PlayerControlView @JvmOverloads constructor( val binding = PlayerControlViewBinding.inflate(LayoutInflater.from(context), this, true) - var onNextClick: () -> Unit = { playbackModel.playNext() } - - var onPreviousClick: () -> Unit = { playbackModel.playPrevious() } + var navigator: MediaNavigator? = null init { if (!isInEditMode) { @@ -135,9 +134,9 @@ class PlayerControlView @JvmOverloads constructor( playbackModel.setShuffle(binding.ivRandom.tag == TAG_CLICK_COMMAND_SHUFFLE) } - binding.ivNext.setOnClickListener { onNextClick() } + binding.ivNext.setOnClickListener { navigator?.showNext() } - binding.ivPrevious.setOnClickListener { onPreviousClick() } + binding.ivPrevious.setOnClickListener { navigator?.showPrevious() } binding.progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { @@ -167,7 +166,7 @@ class PlayerControlView @JvmOverloads constructor( renderRepeatButton(playbackState.repeatMode == RepeatMode.SINGLE) renderShuffleButton(playbackState.shuffle) renderPlayPauseButton(playbackState.currentItemState?.playerState == PlayerState.PLAYING) - renderNextPreviousButtons(playbackState) + renderNextPreviousButtons() renderProgressBar(playbackState.currentItemState) } @@ -207,9 +206,9 @@ class PlayerControlView @JvmOverloads constructor( binding.ivPlayPause.tag = if (isPlaying) TAG_CLICK_COMMAND_PAUSE else TAG_CLICK_COMMAND_PLAY } - private fun renderNextPreviousButtons(playbackState: PlaybackState) { - binding.ivNext.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.size > 1) - binding.ivPrevious.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.isNotEmpty()) + private fun renderNextPreviousButtons() { + binding.ivNext.isEnabled = navigator?.hasNext == true + binding.ivPrevious.isEnabled = navigator?.hasPrevious == true } private fun renderProgressBar(playbackItemState: PlaybackItemState?) { diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt index 94948c2f2586..455a84e9f03b 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -19,6 +19,7 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.widget.ViewPager2 import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.ui.MediaNavigator import com.nextcloud.client.player.util.PlayerUtil.rotate import com.owncloud.android.R @@ -28,7 +29,8 @@ private const val FIRST_ENTITY_POSITION = 1 private const val OFFSCREEN_PAGE_LIMIT = 1 class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - LinearLayout(context, attrs) { + LinearLayout(context, attrs), + MediaNavigator { @SuppressLint("WrongConstant") private val viewPager = ViewPager2(context).apply { @@ -90,11 +92,15 @@ class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSe notifyDataSetChangedWithoutCallingListener() } - fun showNext() { + override val hasNext: Boolean get() = adapter.itemCount > 1 + + override val hasPrevious: Boolean get() = adapter.itemCount > 1 + + override fun showNext() { viewPager.setCurrentItem(viewPager.currentItem + 1, true) } - fun showPrevious() { + override fun showPrevious() { viewPager.setCurrentItem(viewPager.currentItem - 1, true) } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index fa864f7126d6..ab6c3ac7feee 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -7,10 +7,8 @@ package com.nextcloud.client.player.ui.video -import android.graphics.Rect import android.os.Bundle import android.view.LayoutInflater -import android.view.SurfaceView import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible @@ -23,6 +21,7 @@ import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.VideoSize import com.nextcloud.client.player.util.PlayerUtil.applyVideoSize import com.nextcloud.client.player.util.PlayerUtil.getPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.ownsPlayback import com.nextcloud.client.player.util.PlayerUtil.putPlaybackFile import com.owncloud.android.R import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding @@ -123,7 +122,7 @@ class VideoFileFragment : } private fun showVideo(videoSize: VideoSize?) { - if (binding.surfaceView.ownsPlayback()) { + if (ownsPlayback(binding.surfaceView)) { playerModel.setVideoSurfaceView(binding.surfaceView) } binding.surfaceView.isVisible = true @@ -133,6 +132,4 @@ class VideoFileFragment : binding.surfaceView.applyVideoSize(videoSize) } - - private fun SurfaceView.ownsPlayback(): Boolean = isResumed || getGlobalVisibleRect(Rect()) } diff --git a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt index d35356e1749e..728c5478b999 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt @@ -13,6 +13,7 @@ import android.content.ContentResolver import android.content.Context import android.content.pm.PackageManager import android.database.ContentObserver +import android.graphics.Rect import android.net.Uri import android.os.Build import android.os.Bundle @@ -22,6 +23,7 @@ import android.os.Process import android.view.SurfaceView import android.view.View import android.view.ViewGroup +import androidx.fragment.app.Fragment import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player @@ -249,6 +251,9 @@ object PlayerUtil { return mode == AppOpsManager.MODE_ALLOWED } + fun Fragment.ownsPlayback(surfaceView: SurfaceView): Boolean = + isResumed || surfaceView.getGlobalVisibleRect(Rect()) + fun List.rotate(shift: Int): List { val copy = ArrayList(this) Collections.rotate(copy, shift) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt index cf68de86e867..e4362c04e399 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -560,6 +560,9 @@ class AlbumItemsFragment : PreviewImageFragment.canBePreviewed(file) -> activity.startImagePreview(file, !file.isDown, VirtualFolderType.ALBUM) + MimeTypeUtil.isVideo(file) -> + activity.startImagePreview(file, true, VirtualFolderType.ALBUM) + file.isDown && canBePreviewed(file) -> activity.startMediaPreview(file, showPreview = true, streamMedia = false) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 2590835a8b8d..bd277ff037dc 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -11,6 +11,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.Configuration import android.os.Bundle import android.view.MenuItem import android.view.View @@ -22,6 +23,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.Lifecycle import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback @@ -33,6 +35,8 @@ import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.download.SendShareDownloader +import com.nextcloud.client.player.ui.MediaNavigator +import com.nextcloud.client.player.ui.VideoPictureInPicture import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.model.WorkerState import com.nextcloud.utils.extensions.getParcelableArgument @@ -74,6 +78,7 @@ class PreviewImageActivity : OnRemoteOperationListener, OnFilesRemovedListener, SendShareDialog.SendShareDialogDownloader, + MediaNavigator, Injectable { private var livePhotoFile: OCFile? = null private var viewPager: ViewPager2? = null @@ -92,6 +97,10 @@ class PreviewImageActivity : private var isDownloadWorkStarted = false private var screenState = PreviewImageActivityState.Idle + private val pictureInPicture by lazy { VideoPictureInPicture(this, playbackModel, autoEnter = false) } + private var wasSystemUiVisibleBeforePictureInPicture = true + private var keepPlaybackOnFinish = false + @Inject lateinit var preferences: AppPreferences @@ -448,13 +457,68 @@ class PreviewImageActivity : FileDownloadHelper.instance().downloadFileIfNotStartedBefore(user, file) } - fun showFilePage(localId: Long) { - val position = previewMediaPagerAdapter?.getPositionByLocalId(localId) ?: return - if (position < 0 || viewPager?.currentItem == position) return + fun showFilePage(localId: Long): Boolean { + val position = previewMediaPagerAdapter?.getPositionByLocalId(localId) ?: NO_POSITION + if (position < 0) return false + if (viewPager?.currentItem != position) { + viewPager?.setCurrentItem(position, true) + } + + return true + } + + fun finishKeepingPlayback() { + keepPlaybackOnFinish = true + finish() + } + + override val hasNext: Boolean + get() = currentPage() + 1 < (previewMediaPagerAdapter?.itemCount ?: 0) + + override val hasPrevious: Boolean + get() = currentPage() > 0 + + override fun showNext() = showPage(currentPage() + 1) + + override fun showPrevious() = showPage(currentPage() - 1) + + private fun currentPage(): Int = viewPager?.currentItem ?: 0 + + private fun showPage(position: Int) { + val itemCount = previewMediaPagerAdapter?.itemCount ?: return + if (position < 0 || position >= itemCount) return viewPager?.setCurrentItem(position, true) } + fun enterPictureInPicture(): Boolean = viewPager?.let { pictureInPicture.enter(it) } == true + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (isFinishing || isInPictureInPictureMode) return + + initViewPager() + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + + if (isInPictureInPictureMode) { + wasSystemUiVisibleBeforePictureInPicture = isSystemUIVisible + toggleActionBarVisibility(true) + return + } + + toggleActionBarVisibility(!wasSystemUiVisibleBeforePictureInPicture) + + if (lifecycle.currentState != Lifecycle.State.CREATED) return + + if (!keepPlaybackOnFinish) { + playbackModel.release() + } + finish() + } + /** * This method will be invoked when a new page becomes selected. Animation is not necessarily * complete. @@ -576,6 +640,7 @@ class PreviewImageActivity : const val EXTRA_VIRTUAL_TYPE: String = "EXTRA_VIRTUAL_TYPE" private const val KEY_WAITING_FOR_BINDER = "WAITING_FOR_BINDER" private const val KEY_SYSTEM_VISIBLE = "TRUE" + private const val NO_POSITION = -1 fun previewFileIntent(context: Context?, user: User?, file: OCFile?): Intent = Intent(context, PreviewImageActivity::class.java).apply { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index b32e008764fd..285df00a4b8e 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -115,6 +115,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } else { mediaFiles = mStorageManager.getVirtualFolderContent(type, true) } + mediaFiles = mediaFiles.filter { MimeTypeUtil.isImageOrVideo(it) }.toMutableList() if (type == VirtualFolderType.FAVORITE) { val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index d1a31dbb2eb6..92e3b82131ba 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -14,20 +14,20 @@ import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.activity.addCallback import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile -import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.model.state.PlaybackState -import com.nextcloud.client.player.model.state.PlayerState import com.nextcloud.client.player.model.state.VideoSize -import com.nextcloud.client.player.ui.PlayerActivity +import com.nextcloud.client.player.ui.MediaNavigator import com.nextcloud.client.player.ui.PlayerLauncher import com.nextcloud.client.player.util.PlayerUtil.applyVideoSize import com.nextcloud.client.player.util.PlayerUtil.isPictureInPictureAllowed +import com.nextcloud.client.player.util.PlayerUtil.ownsPlayback import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R @@ -96,8 +96,9 @@ class PreviewPlaybackFragment : binding = PreviewPlaybackFragmentBinding.inflate(inflater, container, false) loadThumbnail() registerPictureInPictureOnBack() + binding.playerControlView.navigator = activity as? MediaNavigator binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - if (isResumed) render(playbackModel.state) + if (ownsPlayback(binding.surfaceView)) render(playbackModel.state) } return binding.root } @@ -111,24 +112,26 @@ class PreviewPlaybackFragment : viewLifecycleOwner, enabled = false ) { - val resumePlayback = playbackModel.state?.currentItemState?.playerState == PlayerState.PLAYING - startActivity( - PlayerActivity.createPictureInPictureIntent( - requireContext(), - PlaybackFileType.VIDEO, - resumePlayback - ) - ) - requireActivity().finish() + if (previewActivity()?.enterPictureInPicture() == true) { + return@addCallback + } + + isEnabled = false + requireActivity().onBackPressedDispatcher.onBackPressed() } } + override fun onStart() { + super.onStart() + playbackModel.addListener(this) + render(playbackModel.state) + } + override fun onResume() { super.onResume() playbackModel.onPictureInPictureClose?.invoke() pictureInPictureCallback?.isEnabled = true preparePlayback() - playbackModel.addListener(this) binding.playerControlView.onStart() render(playbackModel.state) } @@ -136,14 +139,24 @@ class PreviewPlaybackFragment : override fun onPause() { pictureInPictureCallback?.isEnabled = false binding.playerControlView.onStop() - playbackModel.removeListener(this) - if (isCurrentItem(playbackModel.state)) { + if (!isInPictureInPictureMode() && isCurrentItem(playbackModel.state)) { playbackModel.pause() playbackModel.setVideoSurfaceView(null) } super.onPause() } + override fun onStop() { + playbackModel.removeListener(this) + super.onStop() + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + binding.playerControlView.isVisible = !isInPictureInPictureMode + render(playbackModel.state) + } + override fun onPlaybackUpdate(state: PlaybackState) { render(state) } @@ -175,21 +188,36 @@ class PreviewPlaybackFragment : } private fun render(state: PlaybackState?) { - if (!isCurrentItem(state)) { - binding.surfaceView.visibility = View.GONE - showPageOfCurrentItem(state) + if (isCurrentItem(state)) { + showVideo(state?.currentItemState?.videoSize) return } - showVideo(state?.currentItemState?.videoSize) + + val wasShowingPlayback = ownsPlayback(binding.surfaceView) + binding.surfaceView.visibility = View.GONE + if (wasShowingPlayback) { + showPageOfCurrentItem(state) + } } private fun showPageOfCurrentItem(state: PlaybackState?) { - val localId = state?.currentItemState?.file?.id?.toLongOrNull() ?: return - (activity as? PreviewImageActivity)?.showFilePage(localId) + val previewActivity = previewActivity() ?: return + val localId = state?.currentItemState?.file?.id?.toLongOrNull() + if (localId != null && previewActivity.showFilePage(localId)) return + + if (isInPictureInPictureMode()) { + previewActivity.finishKeepingPlayback() + } } + private fun previewActivity(): PreviewImageActivity? = activity as? PreviewImageActivity + + private fun isInPictureInPictureMode(): Boolean = activity?.isInPictureInPictureMode == true + private fun showVideo(videoSize: VideoSize?) { - playbackModel.setVideoSurfaceView(binding.surfaceView) + if (ownsPlayback(binding.surfaceView)) { + playbackModel.setVideoSurfaceView(binding.surfaceView) + } binding.surfaceView.visibility = View.VISIBLE binding.surfaceView.alpha = if (videoSize != null) SURFACE_ALPHA_VISIBLE else SURFACE_ALPHA_HIDDEN From bb370e8467394af39e65e068939d82d907e17704 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 12:00:51 +0200 Subject: [PATCH 48/69] wip Signed-off-by: alperozturk96 --- .../resumption/PlaybackResumptionConfig.kt | 4 +- .../PlaybackResumptionConfigStore.kt | 21 ++- .../resumption/PlaybackResumptionLauncher.kt | 4 +- .../player/model/file/PlaybackCollection.kt | 33 +++++ .../model/file/PlaybackFilesComparator.kt | 4 + .../model/file/PlaybackFilesRepository.kt | 27 +++- .../client/player/ui/PlayerLauncher.kt | 16 +-- .../ui/activity/FileDisplayActivity.kt | 5 +- .../ui/preview/PreviewImageFragment.kt | 3 +- .../ui/preview/PreviewMediaPagerAdapter.kt | 128 ++++++++---------- .../ui/preview/PreviewPlaybackFragment.kt | 22 +-- 11 files changed, 156 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/player/model/file/PlaybackCollection.kt diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt index 6c6fb1feb28b..e28d31cde193 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt @@ -7,12 +7,12 @@ package com.nextcloud.client.player.media3.resumption +import com.nextcloud.client.player.model.file.PlaybackCollection import com.nextcloud.client.player.model.file.PlaybackFileType -import com.owncloud.android.ui.fragment.SearchType data class PlaybackResumptionConfig( val currentFileId: String, val folderId: Long, val fileType: PlaybackFileType, - val searchType: SearchType? + val collection: PlaybackCollection ) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt index e6f05124c57f..f506ccc02736 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt @@ -9,8 +9,8 @@ package com.nextcloud.client.player.media3.resumption import android.content.Context import androidx.core.content.edit +import com.nextcloud.client.player.model.file.PlaybackCollection import com.nextcloud.client.player.model.file.PlaybackFileType -import com.owncloud.android.ui.fragment.SearchType import javax.inject.Inject class PlaybackResumptionConfigStore @Inject constructor(private val context: Context) { @@ -19,7 +19,7 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con private const val CURRENT_FILE_ID_KEY = "current_file_id" private const val FOLDER_ID_KEY = "folder_id" private const val FILE_TYPE_KEY = "file_type" - private const val SEARCH_TYPE_KEY = "search_type" + private const val COLLECTION_KEY = "collection" } private val preferences by lazy { @@ -30,20 +30,26 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con val currentFileId = preferences.getString(CURRENT_FILE_ID_KEY, null) val folderId = preferences.getLong(FOLDER_ID_KEY, 0L) val fileType = preferences.getString(FILE_TYPE_KEY, null)?.let(::playbackFileType) - val searchType = preferences.getString(SEARCH_TYPE_KEY, null)?.let(::searchType) + val collection = preferences.getString(COLLECTION_KEY, null)?.let(::playbackCollection) + ?: PlaybackCollection.FOLDER return if (currentFileId != null && folderId != 0L && fileType != null) { - PlaybackResumptionConfig(currentFileId, folderId, fileType, searchType) + PlaybackResumptionConfig(currentFileId, folderId, fileType, collection) } else { null } } - fun saveConfig(currentFileId: String, folderId: Long, fileType: PlaybackFileType, searchType: SearchType?) { + fun saveConfig( + currentFileId: String, + folderId: Long, + fileType: PlaybackFileType, + collection: PlaybackCollection + ) { preferences.edit { putString(CURRENT_FILE_ID_KEY, currentFileId) putLong(FOLDER_ID_KEY, folderId) putString(FILE_TYPE_KEY, fileType.value) - putString(SEARCH_TYPE_KEY, searchType?.name) + putString(COLLECTION_KEY, collection.name) } } @@ -57,5 +63,6 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con it.value == value } - private fun searchType(name: String): SearchType? = SearchType.entries.firstOrNull { it.name == name } + private fun playbackCollection(name: String): PlaybackCollection? = + PlaybackCollection.entries.firstOrNull { it.name == name } } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt index f34d46b58b0f..7e73e3fd345c 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt @@ -29,9 +29,9 @@ class PlaybackResumptionLauncher @Inject constructor( ) { suspend fun launch(): MediaItemsWithStartPosition = runCatching { - val (currentFileId, folderId, fileType, searchType) = playbackResumptionConfigStore.loadConfig() + val (currentFileId, folderId, fileType, collection) = playbackResumptionConfigStore.loadConfig() ?: throw IllegalStateException("Playback resumption config is null") - val playbackFilesFlow = playbackFilesRepository.observe(folderId, fileType, searchType) + val playbackFilesFlow = playbackFilesRepository.observe(folderId, fileType, collection) val playbackFiles = playbackFilesFlow.first().list.ifEmpty { throw IllegalStateException("Playback files are empty") } diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackCollection.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackCollection.kt new file mode 100644 index 000000000000..7155f2f16e98 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackCollection.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.file + +import com.owncloud.android.datamodel.VirtualFolderType +import com.owncloud.android.ui.fragment.SearchType + +enum class PlaybackCollection { + FOLDER, + FAVORITES, + GALLERY, + SHARED, + ALBUM +} + +fun SearchType?.toPlaybackCollection(): PlaybackCollection = when (this) { + SearchType.FAVORITE_SEARCH -> PlaybackCollection.FAVORITES + SearchType.GALLERY_SEARCH -> PlaybackCollection.GALLERY + SearchType.SHARED_FILTER -> PlaybackCollection.SHARED + else -> PlaybackCollection.FOLDER +} + +fun VirtualFolderType?.toPlaybackCollection(): PlaybackCollection = when (this) { + VirtualFolderType.FAVORITE -> PlaybackCollection.FAVORITES + VirtualFolderType.GALLERY -> PlaybackCollection.GALLERY + VirtualFolderType.ALBUM -> PlaybackCollection.ALBUM + else -> PlaybackCollection.FOLDER +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt index 4465abb8a212..33c6b918690f 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt @@ -20,6 +20,10 @@ sealed interface PlaybackFilesComparator : Comparator { override fun compare(a: PlaybackFile, b: PlaybackFile): Int = AlphanumericComparator.compare(a.name, b.name) } + object ALBUM : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified } + } + object GALLERY : PlaybackFilesComparator { override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified } } diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt index e942cc4d1b4c..51cf803db4ce 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt @@ -15,8 +15,8 @@ import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.MainApp import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.db.ProviderMeta.ProviderTableMeta -import com.owncloud.android.ui.fragment.SearchType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.debounce @@ -36,12 +36,25 @@ class PlaybackFilesRepository @Inject constructor( private val preferences: AppPreferences ) { - fun observe(folderId: Long, fileType: PlaybackFileType, searchType: SearchType?): Flow = - when (searchType) { - SearchType.FAVORITE_SEARCH -> observeFavoritePlaybackFiles(fileType) - SearchType.GALLERY_SEARCH -> observeGalleryPlaybackFiles(fileType) - SearchType.SHARED_FILTER -> observeSharedPlaybackFiles(fileType) - else -> observeFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice()) + fun observe(folderId: Long, fileType: PlaybackFileType, collection: PlaybackCollection): Flow = + when (collection) { + PlaybackCollection.FAVORITES -> observeFavoritePlaybackFiles(fileType) + PlaybackCollection.GALLERY -> observeGalleryPlaybackFiles(fileType) + PlaybackCollection.SHARED -> observeSharedPlaybackFiles(fileType) + PlaybackCollection.ALBUM -> observeAlbumPlaybackFiles(fileType) + PlaybackCollection.FOLDER -> observeFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice()) + } + + private fun observeAlbumPlaybackFiles(fileType: PlaybackFileType): Flow = + observeData(ProviderTableMeta.CONTENT_URI, true) { + withContext(Dispatchers.IO) { + storageManager.getVirtualFolderContent(VirtualFolderType.ALBUM, false) + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(PlaybackFilesComparator.ALBUM) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.ALBUM) } + } } private fun observeFavoritePlaybackFiles(fileType: PlaybackFileType): Flow = diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt index 44b1e05f3003..c316d2fbaddd 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt @@ -13,13 +13,13 @@ import androidx.lifecycle.lifecycleScope import com.nextcloud.client.logger.Logger import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore +import com.nextcloud.client.player.model.file.PlaybackCollection import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.file.PlaybackFilesComparator import com.nextcloud.client.player.model.file.PlaybackFilesRepository import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.ui.fragment.SearchType import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.util.concurrent.CancellationException @@ -36,9 +36,9 @@ class PlayerLauncher @Inject constructor( /** * Starts playback and opens [PlayerActivity] on top of [activity]. */ - fun launch(activity: AppCompatActivity, file: OCFile, searchType: SearchType?) { + fun launch(activity: AppCompatActivity, file: OCFile, collection: PlaybackCollection) { run(activity) { - val fileType = prepareQueue(file, searchType) + val fileType = prepareQueue(file, collection) playbackModel.play() activity.startActivity(PlayerActivity.createIntent(activity, fileType)) } @@ -48,9 +48,9 @@ class PlayerLauncher @Inject constructor( * Loads [file] and the media of its collection into the player without opening [PlayerActivity], so that a host * screen can render the playback itself. */ - fun prepare(owner: LifecycleOwner, file: OCFile, searchType: SearchType?, autoplay: Boolean = false) { + fun prepare(owner: LifecycleOwner, file: OCFile, collection: PlaybackCollection, autoplay: Boolean = false) { run(owner) { - prepareQueue(file, searchType) + prepareQueue(file, collection) if (autoplay) { playbackModel.play() } @@ -67,13 +67,13 @@ class PlayerLauncher @Inject constructor( } } - private suspend fun prepareQueue(file: OCFile, searchType: SearchType?): PlaybackFileType { + private suspend fun prepareQueue(file: OCFile, collection: PlaybackCollection): PlaybackFileType { val fileType = PlaybackFileType.ofMimeType(file.mimeType) - playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, searchType) + playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, collection) playbackModel.start() playbackModel.setFiles(PlaybackFiles(listOf(file.toPlaybackFile()), PlaybackFilesComparator.NONE)) - playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, searchType)) + playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, collection)) return fileType } } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 537ec68872c5..b200545437d2 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -72,6 +72,7 @@ import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.player.ui.PlayerLauncher import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.utils.IntentUtil @@ -2746,8 +2747,8 @@ class FileDisplayActivity : } private fun startMediaActivity(file: OCFile) { - val searchType = listOfFilesFragment?.currentSearchType - playerLauncher.launch(this, file, searchType) + val collection = listOfFilesFragment?.currentSearchType.toPlaybackCollection() + playerLauncher.launch(this, file, collection) } fun configureToolbarForPreview(file: OCFile?) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index 7fd1b90db60c..963f01b6134d 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -57,6 +57,7 @@ import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSmallThumbnail import com.nextcloud.utils.extensions.typedActivity import com.owncloud.android.MainApp +import com.nextcloud.client.player.model.file.PlaybackCollection import com.owncloud.android.R import com.owncloud.android.databinding.PreviewImageFragmentBinding import com.owncloud.android.datamodel.OCFile @@ -168,7 +169,7 @@ class PreviewImageFragment : private fun playLivePhoto(file: OCFile) { hideActionBar() - val mediaFragment = PreviewPlaybackFragment.newInstance(file, searchType = null, autoplay = true) + val mediaFragment = PreviewPlaybackFragment.newInstance(file, PlaybackCollection.FOLDER, autoplay = true) requireActivity().supportFragmentManager.beginTransaction().run { replace(R.id.top, mediaFragment) addToBackStack(null) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index 285df00a4b8e..6aa4d8b0f201 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -15,45 +15,29 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import com.nextcloud.client.account.User +import com.nextcloud.client.player.model.file.PlaybackCollection +import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.ui.fragment.SearchType import com.owncloud.android.utils.FileSortOrder import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.MimeTypeUtil -/** - * Adapter class that provides Fragment instances - */ class PreviewMediaPagerAdapter : FragmentStateAdapter { - var autoplayFileId: Long? = null - private var selectedFile: OCFile? = null private var mediaFiles: MutableList = mutableListOf() private val user: User - private val mObsoleteFragments: MutableSet - private val mObsoletePositions: MutableSet - private val mDownloadErrors: MutableSet - private val mStorageManager: FileDataStorageManager - private val mCachedFragments: SparseArray - - /** - * The collection the pages come from, so that a media page can build the same playback queue. - */ - private val searchType: SearchType? - - /** - * Constructor - * - * @param fragmentActivity [FragmentActivity] instance that will handle the [Fragment]s provided by the - * adapter. - * @param parentFolder Folder where images will be searched for. - * @param storageManager Bridge to database. - */ + private val obsoleteFragments: MutableSet + private val obsoletePositions: MutableSet + private val downloadErrors: MutableSet + private val storageManager: FileDataStorageManager + private val cachedFragments: SparseArray + private val playbackCollection: PlaybackCollection + @Suppress("LongParameterList") constructor( fragmentActivity: FragmentActivity, @@ -68,29 +52,21 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this.user = user this.selectedFile = selectedFile - mStorageManager = storageManager - searchType = null - mediaFiles = mStorageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) + this@PreviewMediaPagerAdapter.storageManager = storageManager + playbackCollection = PlaybackCollection.FOLDER + mediaFiles = storageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) val sortOrder = preferences.getSortOrderByFolder(parentFolder) val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() val favoritesFirst = preferences.isSortFavoritesFirst() mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) - mObsoleteFragments = HashSet() - mObsoletePositions = HashSet() - mDownloadErrors = HashSet() - mCachedFragments = SparseArray() + obsoleteFragments = HashSet() + obsoletePositions = HashSet() + downloadErrors = HashSet() + cachedFragments = SparseArray() } - /** - * Constructor - * - * @param fragmentActivity [FragmentActivity] instance that will handle the [Fragment]s provided by the - * adapter. - * @param type Type of virtual folder, e.g. favorite or photos - * @param storageManager Bridge to database. - */ constructor( fragmentActivity: FragmentActivity, type: VirtualFolderType?, @@ -102,32 +78,38 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { require(type != VirtualFolderType.NONE) { "NONE virtual folder type" } this.user = user - mStorageManager = storageManager - searchType = when (type) { - VirtualFolderType.GALLERY -> SearchType.GALLERY_SEARCH - VirtualFolderType.FAVORITE -> SearchType.FAVORITE_SEARCH - else -> null - } + this@PreviewMediaPagerAdapter.storageManager = storageManager + playbackCollection = type.toPlaybackCollection() - if (type == VirtualFolderType.GALLERY) { - mediaFiles = mStorageManager.allGalleryItems - mediaFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) + mediaFiles = loadVirtualFolderMediaFiles(type, preferences) + + obsoleteFragments = HashSet() + obsoletePositions = HashSet() + downloadErrors = HashSet() + cachedFragments = SparseArray() + } + + private fun loadVirtualFolderMediaFiles( + type: VirtualFolderType, + preferences: AppPreferences + ): MutableList { + val source = if (type == VirtualFolderType.GALLERY) { + storageManager.allGalleryItems } else { - mediaFiles = mStorageManager.getVirtualFolderContent(type, true) + storageManager.getVirtualFolderContent(type, false) } - mediaFiles = mediaFiles.filter { MimeTypeUtil.isImageOrVideo(it) }.toMutableList() + val mediaFiles = source.filter { MimeTypeUtil.isImageOrVideo(it) }.toMutableList() - if (type == VirtualFolderType.FAVORITE) { - val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) - val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() - val favoritesFirst = preferences.isSortFavoritesFirst() - mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) + if (type != VirtualFolderType.FAVORITE) { + return FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) } - mObsoleteFragments = HashSet() - mObsoletePositions = HashSet() - mDownloadErrors = HashSet() - mCachedFragments = SparseArray() + val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) + return sortOrder.sortCloudFiles( + mediaFiles, + preferences.isSortFoldersBeforeFiles(), + preferences.isSortFavoritesFirst() + ) } fun delete(position: Int) { @@ -135,15 +117,15 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { return } - mCachedFragments[position]?.let { - mObsoleteFragments.add(it) + cachedFragments[position]?.let { + obsoleteFragments.add(it) } - mObsoletePositions.add(position) + obsoletePositions.add(position) mediaFiles.removeAt(position) - mDownloadErrors.remove(position) - mCachedFragments.remove(position) + downloadErrors.remove(position) + cachedFragments.remove(position) notifyItemRemoved(position) } @@ -160,8 +142,8 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } fun getItem(i: Int): Fragment { - val fragment = fragmentFor(getFileAt(i), i, mObsoletePositions.contains(i)) - mObsoletePositions.remove(i) + val fragment = fragmentFor(getFileAt(i), i, obsoletePositions.contains(i)) + obsoletePositions.remove(i) return fragment } @@ -173,7 +155,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { private fun fragmentForDownloaded(file: OCFile, ignoreFirstSavedState: Boolean): Fragment = if (file.isAudioOrVideo()) { - PreviewPlaybackFragment.newInstance(file, searchType, takeAutoplay(file)) + PreviewPlaybackFragment.newInstance(file, playbackCollection, takeAutoplay(file)) } else { PreviewImageFragment.newInstance(file, ignoreFirstSavedState, false) } @@ -182,14 +164,14 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { addVideoOfLivePhoto(file) return when { - mDownloadErrors.remove(position) -> + downloadErrors.remove(position) -> FileDownloadFragment.newInstance(file, user, true).apply { setError(true) } // The FileDownloadFragment is used exclusively for encrypted files, as they cannot be previewed // without first being downloaded. file.isEncrypted -> FileDownloadFragment.newInstance(file, user, ignoreFirstSavedState) - file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, searchType, takeAutoplay(file)) + file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, playbackCollection, takeAutoplay(file)) else -> PreviewImageFragment.newInstance(file, ignoreFirstSavedState, true) } @@ -215,12 +197,12 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { return } - mCachedFragments[position]?.let { mObsoleteFragments.add(it) } - mObsoletePositions.add(position) + cachedFragments[position]?.let { obsoleteFragments.add(it) } + obsoletePositions.add(position) mediaFiles[position] = file } - fun pendingErrorAt(position: Int): Boolean = mDownloadErrors.contains(position) + fun pendingErrorAt(position: Int): Boolean = downloadErrors.contains(position) override fun createFragment(position: Int): Fragment = getItem(position) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index 92e3b82131ba..ad0c23596c7a 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -19,6 +19,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackCollection import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.nextcloud.client.player.model.state.PlaybackState @@ -33,7 +34,6 @@ import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R import com.owncloud.android.databinding.PreviewPlaybackFragmentBinding import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.ui.fragment.SearchType import com.owncloud.android.utils.MimeTypeUtil import dagger.android.support.AndroidSupportInjection import kotlinx.coroutines.launch @@ -50,16 +50,16 @@ class PreviewPlaybackFragment : companion object { private const val ARGUMENT_FILE = "ARGUMENT_FILE" - private const val ARGUMENT_SEARCH_TYPE = "ARGUMENT_SEARCH_TYPE" + private const val ARGUMENT_COLLECTION = "ARGUMENT_COLLECTION" private const val ARGUMENT_AUTOPLAY = "ARGUMENT_AUTOPLAY" private const val SURFACE_ALPHA_VISIBLE = 1f private const val SURFACE_ALPHA_HIDDEN = 0f - fun newInstance(file: OCFile, searchType: SearchType?, autoplay: Boolean = false) = + fun newInstance(file: OCFile, collection: PlaybackCollection, autoplay: Boolean = false) = PreviewPlaybackFragment().apply { arguments = bundleOf( ARGUMENT_FILE to file, - ARGUMENT_SEARCH_TYPE to searchType, + ARGUMENT_COLLECTION to collection, ARGUMENT_AUTOPLAY to autoplay ) } @@ -77,8 +77,9 @@ class PreviewPlaybackFragment : private lateinit var binding: PreviewPlaybackFragmentBinding private lateinit var file: OCFile private lateinit var playbackFile: PlaybackFile - private var searchType: SearchType? = null + private var playbackCollection = PlaybackCollection.FOLDER private var autoplay: Boolean = false + private var wasCurrentItem = false private var pictureInPictureCallback: OnBackPressedCallback? = null @@ -88,7 +89,8 @@ class PreviewPlaybackFragment : file = arguments.getParcelableArgument(ARGUMENT_FILE, OCFile::class.java) ?: throw IllegalArgumentException("bundle is not containing a file") playbackFile = file.toPlaybackFile() - searchType = arguments.getSerializableArgument(ARGUMENT_SEARCH_TYPE, SearchType::class.java) + playbackCollection = arguments.getSerializableArgument(ARGUMENT_COLLECTION, PlaybackCollection::class.java) + ?: PlaybackCollection.FOLDER autoplay = arguments?.getBoolean(ARGUMENT_AUTOPLAY) == true } @@ -173,7 +175,7 @@ class PreviewPlaybackFragment : playbackModel.play() } } else { - playerLauncher.prepare(this, file, searchType, autoplay) + playerLauncher.prepare(this, file, playbackCollection, autoplay) } } @@ -189,13 +191,15 @@ class PreviewPlaybackFragment : private fun render(state: PlaybackState?) { if (isCurrentItem(state)) { + wasCurrentItem = true showVideo(state?.currentItemState?.videoSize) return } - val wasShowingPlayback = ownsPlayback(binding.surfaceView) + val playbackMovedOn = wasCurrentItem && ownsPlayback(binding.surfaceView) + wasCurrentItem = false binding.surfaceView.visibility = View.GONE - if (wasShowingPlayback) { + if (playbackMovedOn) { showPageOfCurrentItem(state) } } From 50e01fe256a7229aefd393cca55a673e175f0706 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 12:03:52 +0200 Subject: [PATCH 49/69] wip Signed-off-by: alperozturk96 --- .../android/ui/preview/PreviewMediaPagerAdapter.kt | 2 +- .../android/ui/preview/PreviewPlaybackFragment.kt | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index 6aa4d8b0f201..ed4786858c58 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2018 Tobias Kaminsky * SPDX-FileCopyrightText: 2020 Chris Narkiewicz * SPDX-FileCopyrightText: 2015 ownCloud Inc. diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index ad0c23596c7a..ff75b2e5de3f 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -99,6 +99,7 @@ class PreviewPlaybackFragment : loadThumbnail() registerPictureInPictureOnBack() binding.playerControlView.navigator = activity as? MediaNavigator + updatePlayerControlsVisibility() binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> if (ownsPlayback(binding.surfaceView)) render(playbackModel.state) } @@ -155,7 +156,6 @@ class PreviewPlaybackFragment : override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) - binding.playerControlView.isVisible = !isInPictureInPictureMode render(playbackModel.state) } @@ -190,6 +190,8 @@ class PreviewPlaybackFragment : } private fun render(state: PlaybackState?) { + updatePlayerControlsVisibility() + if (isCurrentItem(state)) { wasCurrentItem = true showVideo(state?.currentItemState?.videoSize) @@ -218,6 +220,10 @@ class PreviewPlaybackFragment : private fun isInPictureInPictureMode(): Boolean = activity?.isInPictureInPictureMode == true + private fun updatePlayerControlsVisibility() { + binding.playerControlView.isVisible = !isInPictureInPictureMode() + } + private fun showVideo(videoSize: VideoSize?) { if (ownsPlayback(binding.surfaceView)) { playbackModel.setVideoSurfaceView(binding.surfaceView) From 41eb72306ca517b5684d82dc68a470f20d7e4e0c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 12:04:41 +0200 Subject: [PATCH 50/69] wip Signed-off-by: alperozturk96 --- .../media3/resumption/PlaybackResumptionConfigStore.kt | 7 +------ .../java/com/nextcloud/client/player/util/PlayerUtil.kt | 3 +-- .../android/ui/preview/PreviewMediaPagerAdapter.kt | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt index f506ccc02736..dc3cfe97134d 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt @@ -39,12 +39,7 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con } } - fun saveConfig( - currentFileId: String, - folderId: Long, - fileType: PlaybackFileType, - collection: PlaybackCollection - ) { + fun saveConfig(currentFileId: String, folderId: Long, fileType: PlaybackFileType, collection: PlaybackCollection) { preferences.edit { putString(CURRENT_FILE_ID_KEY, currentFileId) putLong(FOLDER_ID_KEY, folderId) diff --git a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt index 728c5478b999..57903b936077 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt @@ -251,8 +251,7 @@ object PlayerUtil { return mode == AppOpsManager.MODE_ALLOWED } - fun Fragment.ownsPlayback(surfaceView: SurfaceView): Boolean = - isResumed || surfaceView.getGlobalVisibleRect(Rect()) + fun Fragment.ownsPlayback(surfaceView: SurfaceView): Boolean = isResumed || surfaceView.getGlobalVisibleRect(Rect()) fun List.rotate(shift: Int): List { val copy = ArrayList(this) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index ed4786858c58..187a7de23618 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -89,10 +89,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { cachedFragments = SparseArray() } - private fun loadVirtualFolderMediaFiles( - type: VirtualFolderType, - preferences: AppPreferences - ): MutableList { + private fun loadVirtualFolderMediaFiles(type: VirtualFolderType, preferences: AppPreferences): MutableList { val source = if (type == VirtualFolderType.GALLERY) { storageManager.allGalleryItems } else { From aefa37ff2e5d622e0ccd7c1737382eacf287b07d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 12:14:31 +0200 Subject: [PATCH 51/69] use non deprecated apis Signed-off-by: alperozturk96 --- .../session/MediaSessionBitmapLoader.kt | 2 +- .../media3/session/MediaSessionCallback.kt | 5 +- .../model/state/PlaybackItemMetadata.kt | 14 +++++- .../client/player/ui/audio/AudioPlayerView.kt | 4 +- .../client/player/ui/video/VideoPlayerView.kt | 4 +- .../client/player/util/PlayerUtil.kt | 8 +--- .../client/player/util/WindowWrapper.kt | 47 +++++++++++++++---- app/src/main/res/values/ids.xml | 2 + 8 files changed, 61 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt index 3ece7169c72c..021a025b915f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -33,7 +33,7 @@ import javax.inject.Inject class MediaSessionBitmapLoader @Inject constructor( private val context: Context, private val thumbnailLoader: ThumbnailLoader -) : BitmapLoader by DataSourceBitmapLoader(context) { +) : BitmapLoader by DataSourceBitmapLoader.Builder(context).build() { companion object { private const val THUMBNAIL_TARGET_SIZE = 160 diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt index cd94c01708f4..ab1f8538d33e 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -45,7 +45,7 @@ class MediaSessionCallback @Inject constructor( .buildUpon() .add(SessionCommand(CLOSE_ACTION, Bundle.EMPTY)) .build() - return ConnectionResult.AcceptedResultBuilder(session) + return ConnectionResult.AcceptedResultBuilder(session, controller) .setAvailableSessionCommands(sessionCommands) .build() } @@ -64,6 +64,7 @@ class MediaSessionCallback @Inject constructor( override fun onPlaybackResumption( mediaSession: MediaSession, - controller: MediaSession.ControllerInfo + controller: MediaSession.ControllerInfo, + isForPlayback: Boolean ): ListenableFuture = GlobalScope.future { playbackResumptionLauncher.launch() } } diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt index ce7e14b23e97..d8b599ae3177 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt @@ -7,6 +7,8 @@ package com.nextcloud.client.player.model.state +import java.util.Objects + data class PlaybackItemMetadata( val title: CharSequence, val artist: CharSequence? = null, @@ -16,4 +18,14 @@ data class PlaybackItemMetadata( val description: CharSequence? = null, val artworkData: ByteArray? = null, val artworkUri: CharSequence? = null -) +) { + override fun equals(other: Any?): Boolean = when { + this === other -> true + other !is PlaybackItemMetadata -> false + else -> describedFields() == other.describedFields() && artworkData.contentEquals(other.artworkData) + } + + override fun hashCode(): Int = Objects.hash(describedFields(), artworkData?.contentHashCode()) + + private fun describedFields(): List = listOf(title, artist, album, genre, year, description, artworkUri) +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt index 2681412967a7..5ef66985a46c 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt @@ -35,8 +35,8 @@ class AudioPlayerView(context: Context) : PlayerView(context) { playerPager.setPadding(insets.left, 0, insets.right, 0) playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom) - windowWrapper.setupStatusBar(R.color.player_background_color, false) - windowWrapper.setupNavigationBar(R.color.player_background_color, true) + windowWrapper.setupStatusBar(R.color.player_background_color) + windowWrapper.setupNavigationBar(R.color.player_background_color) return WindowInsetsCompat.CONSUMED.toWindowInsets() } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt index f92eeb0fcc44..f0bcf4283c4c 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt @@ -56,8 +56,8 @@ class VideoPlayerView(context: Context) : PlayerView(context) { topBar.setPadding(insets.left, insets.top, insets.right, 0) playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom) - windowWrapper.setupStatusBar(R.color.player_video_toolbar_background_color, false) - windowWrapper.setupNavigationBar(R.color.player_video_control_view_background_color, false) + windowWrapper.setupStatusBar(R.color.player_video_toolbar_background_color) + windowWrapper.setupNavigationBar(R.color.player_video_control_view_background_color) return WindowInsetsCompat.CONSUMED.toWindowInsets() } diff --git a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt index 57903b936077..eee98039bc3f 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt @@ -15,7 +15,6 @@ import android.content.pm.PackageManager import android.database.ContentObserver import android.graphics.Rect import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -242,12 +241,7 @@ object PlayerUtil { return false } - val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - appOpsManager.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) - } else { - @Suppress("DEPRECATION") - appOpsManager.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) - } + val mode = appOpsManager.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) return mode == AppOpsManager.MODE_ALLOWED } diff --git a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt index 5a8fa141836d..0407bd97974f 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt @@ -7,15 +7,23 @@ package com.nextcloud.client.player.util -import android.os.Build +import android.view.Gravity +import android.view.View +import android.view.ViewGroup import android.view.Window +import android.widget.FrameLayout import androidx.annotation.ColorInt import androidx.annotation.ColorRes +import androidx.annotation.IdRes import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.updateLayoutParams +import com.owncloud.android.R private const val LUMINANCE_THRESHOLD = 0.5 @@ -32,21 +40,40 @@ class WindowWrapper(private val window: Window) { insetsController.hide(WindowInsetsCompat.Type.systemBars()) } - fun setupStatusBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) { + fun setupStatusBar(@ColorRes backgroundColorRes: Int) { val backgroundColor = ContextCompat.getColor(context, backgroundColorRes) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - window.setStatusBarContrastEnforced(contrastEnforced) - } insetsController.isAppearanceLightStatusBars = isLightColor(backgroundColor) + drawSystemBarBackground( + R.id.player_status_bar_background, + Gravity.TOP, + backgroundColor, + systemBarInsets(WindowInsetsCompat.Type.statusBars()).top + ) } - fun setupNavigationBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) { + fun setupNavigationBar(@ColorRes backgroundColorRes: Int) { val backgroundColor = ContextCompat.getColor(context, backgroundColorRes) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - window.setNavigationBarContrastEnforced(contrastEnforced) - } - window.navigationBarColor = backgroundColor insetsController.isAppearanceLightNavigationBars = isLightColor(backgroundColor) + drawSystemBarBackground( + R.id.player_navigation_bar_background, + Gravity.BOTTOM, + backgroundColor, + systemBarInsets(WindowInsetsCompat.Type.navigationBars()).bottom + ) + } + + private fun systemBarInsets(typeMask: Int): Insets = + ViewCompat.getRootWindowInsets(window.decorView)?.getInsets(typeMask) ?: Insets.NONE + + private fun drawSystemBarBackground(@IdRes id: Int, gravity: Int, @ColorInt color: Int, height: Int) { + val content = window.findViewById(android.R.id.content) ?: return + val background = content.findViewById(id) ?: View(context).apply { + this.id = id + content.addView(this, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height, gravity)) + } + + background.setBackgroundColor(color) + background.updateLayoutParams { this.height = height } } private fun isLightColor(@ColorInt color: Int): Boolean = ColorUtils.calculateLuminance(color) > LUMINANCE_THRESHOLD diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 3eed0d867c81..f939274621ff 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -43,4 +43,6 @@ + + From a22bdd387dcd6a8241c94acd4ebd74d350b95a1d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 12:57:09 +0200 Subject: [PATCH 52/69] remove guava Signed-off-by: alperozturk96 --- app/build.gradle.kts | 1 - .../client/player/media3/PlaybackModel.kt | 60 +++++++++++-------- .../client/player/media3/PlaybackService.kt | 2 +- .../resumption/PlaybackResumptionLauncher.kt | 2 +- .../session/MediaSessionBitmapLoader.kt | 2 +- .../media3/session/MediaSessionCallback.kt | 20 ++++++- .../player/ui/PlayerProgressIndicator.kt | 2 +- gradle/libs.versions.toml | 1 - 8 files changed, 57 insertions(+), 33 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index daa3fd97efc1..6235237224a3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -527,7 +527,6 @@ dependencies { // region Kotlin implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.guava) // endregion // region Stateless diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index 572a522bd828..252458eda141 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -16,14 +16,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.MediaSession import androidx.media3.session.SessionToken -import com.nextcloud.client.player.util.PlayerUtil.playbackFile -import com.nextcloud.client.player.util.PlayerUtil.readCurrentFiles -import com.nextcloud.client.player.util.PlayerUtil.readMediaIds -import com.nextcloud.client.player.util.PlayerUtil.toPlaybackState -import com.nextcloud.client.player.util.PlayerUtil.toMediaItem -import com.nextcloud.client.player.util.PlayerUtil.indexOfFirst -import com.nextcloud.client.player.util.PlayerUtil.setRepeatMode -import com.nextcloud.client.player.util.PlayerUtil.updateMediaItems +import com.google.common.util.concurrent.ListenableFuture import com.nextcloud.client.player.media3.session.MediaSessionFactory import com.nextcloud.client.player.model.PlaybackSettings import com.nextcloud.client.player.model.file.PlaybackFile @@ -31,6 +24,14 @@ import com.nextcloud.client.player.model.file.PlaybackFiles import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.RepeatMode import com.nextcloud.client.player.util.PeriodicAction +import com.nextcloud.client.player.util.PlayerUtil.indexOfFirst +import com.nextcloud.client.player.util.PlayerUtil.playbackFile +import com.nextcloud.client.player.util.PlayerUtil.readCurrentFiles +import com.nextcloud.client.player.util.PlayerUtil.readMediaIds +import com.nextcloud.client.player.util.PlayerUtil.setRepeatMode +import com.nextcloud.client.player.util.PlayerUtil.toMediaItem +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackState +import com.nextcloud.client.player.util.PlayerUtil.updateMediaItems import com.owncloud.android.datamodel.OCFile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,10 +40,13 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.ExecutionException import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException @Singleton @OptIn(markerClass = [UnstableApi::class]) @@ -58,12 +62,8 @@ class PlaybackModel @Inject constructor( } interface Listener { - fun onPlaybackUpdate(state: PlaybackState) - - fun onPlaybackError(error: Throwable) { - // Default empty implementation - } + fun onPlaybackError(error: Throwable) = Unit } private val listeners = mutableListOf() @@ -140,6 +140,25 @@ class PlaybackModel @Inject constructor( } } + private suspend fun ListenableFuture.await(): T = suspendCancellableCoroutine { cont -> + addListener( + { + try { + cont.resume(get()) + } catch (e: ExecutionException) { + cont.resumeWithException(e.cause ?: e) + } catch (e: Exception) { + cont.resumeWithException(e) + } + }, + Runnable::run + ) + + cont.invokeOnCancellation { + cancel(false) + } + } + fun setFilesFlow(filesFlow: Flow) { controllerScope?.launch { filesFlow @@ -177,7 +196,7 @@ class PlaybackModel @Inject constructor( private fun getNextFileIndex(files: PlaybackFiles, currentFile: PlaybackFile): Int = (files.list + currentFile) .sortedWith(files.comparator) .indexOfFirst { it.id == currentFile.id } - .let { if (it in 0..files.list.lastIndex) it else 0 } + .let { if (it in files.list.indices) it else 0 } fun release() { videoSurfaceView = null @@ -224,13 +243,6 @@ class PlaybackModel @Inject constructor( } } - fun playPrevious() { - controller?.run { - seekToPreviousMediaItem() - prepare() - } - } - fun seekToPosition(positionInMilliseconds: Long) { controller?.seekTo(positionInMilliseconds) } @@ -266,13 +278,13 @@ class PlaybackModel @Inject constructor( private fun notifyPlaybackUpdate() { val currentState = state ?: return - for (i in 0 until listeners.size) { + for (i in listeners.indices) { listeners.getOrNull(i)?.onPlaybackUpdate(currentState) } } private fun notifyPlaybackError(error: Throwable) { - for (i in 0 until listeners.size) { + for (i in listeners.indices) { listeners.getOrNull(i)?.onPlaybackError(error) } } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt index ba063c2eda3b..6b2536aaadce 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt @@ -16,9 +16,9 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSessionService -import com.nextcloud.client.player.util.PlayerUtil.playbackFile import com.nextcloud.client.player.model.file.PlaybackFileType import com.nextcloud.client.player.ui.PlayerActivity +import com.nextcloud.client.player.util.PlayerUtil.playbackFile import dagger.android.AndroidInjection import javax.inject.Inject diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt index 7e73e3fd345c..92a6e3d56017 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt @@ -10,10 +10,10 @@ package com.nextcloud.client.player.media3.resumption import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession.MediaItemsWithStartPosition import com.nextcloud.client.player.media3.PlaybackModel -import com.nextcloud.client.player.util.PlayerUtil.toMediaItem import com.nextcloud.client.player.model.file.PlaybackFile import com.nextcloud.client.player.model.file.PlaybackFilesRepository import com.nextcloud.client.player.util.PlayerUtil.getPlaybackUri +import com.nextcloud.client.player.util.PlayerUtil.toMediaItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt index 021a025b915f..188faef18b5d 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -20,9 +20,9 @@ import androidx.media3.datasource.DataSourceBitmapLoader import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListeningExecutorService import com.google.common.util.concurrent.MoreExecutors -import com.nextcloud.client.player.util.PlayerUtil.playbackFile import com.nextcloud.client.player.model.ThumbnailLoader import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.playbackFile import com.owncloud.android.R import com.owncloud.android.utils.MimeTypeUtil import java.util.concurrent.Callable diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt index ab1f8538d33e..447fab5b75be 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -16,10 +16,12 @@ import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture -import com.nextcloud.client.player.media3.resumption.PlaybackResumptionLauncher +import com.google.common.util.concurrent.SettableFuture import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionLauncher +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.guava.future +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Provider @@ -62,9 +64,21 @@ class MediaSessionCallback @Inject constructor( return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + @OptIn(DelicateCoroutinesApi::class) override fun onPlaybackResumption( mediaSession: MediaSession, controller: MediaSession.ControllerInfo, isForPlayback: Boolean - ): ListenableFuture = GlobalScope.future { playbackResumptionLauncher.launch() } + ): ListenableFuture { + val future = SettableFuture.create() + GlobalScope.launch { + try { + val result = playbackResumptionLauncher.launch() + future.set(result) + } catch (e: Exception) { + future.setException(e) + } + } + return future + } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt index 7c6cb1803e17..65d18a0fbcf4 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt @@ -13,10 +13,10 @@ import androidx.annotation.AttrRes import com.google.android.material.progressindicator.LinearProgressIndicator import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.nextcloud.client.player.model.state.PlaybackItemState import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.owncloud.android.datamodel.OCFile import dagger.android.HasAndroidInjector import javax.inject.Inject diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dceaad669f43..b67a46e131d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -241,7 +241,6 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "w foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesVersion" } -kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesVersion" } [bundles] media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource"] From 219587f1c21464c41b4595d4df7fc5abbe6f3cdb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 14:22:24 +0200 Subject: [PATCH 53/69] fix pip border, and gallery selection Signed-off-by: alperozturk96 --- .../model/file/PlaybackFilesRepository.kt | 2 ++ .../ui/activity/FileDisplayActivity.kt | 21 +++++++++-- .../android/ui/fragment/GalleryFragment.kt | 3 ++ .../ui/preview/PreviewImageActivity.kt | 35 +++++++++++-------- .../ui/preview/PreviewMediaPagerAdapter.kt | 26 +++++++++++--- 5 files changed, 64 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt index 51cf803db4ce..bd35c0afd2ab 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt @@ -72,8 +72,10 @@ class PlaybackFilesRepository @Inject constructor( private fun observeGalleryPlaybackFiles(fileType: PlaybackFileType): Flow = observeData(ProviderTableMeta.CONTENT_URI, true) { withContext(Dispatchers.IO) { + val mediaFolderPath = preferences.getLastSelectedMediaFolder() storageManager.allGalleryItems .asSequence() + .filter { it.remotePath.startsWith(mediaFolderPath) } .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } .map { it.toPlaybackFile() } .sortedWith(PlaybackFilesComparator.GALLERY) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index b200545437d2..e48cdc9f8912 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -80,6 +80,7 @@ import com.nextcloud.model.OCUploadLocalPathData import com.nextcloud.model.WorkerState.OfflineOperationsCompleted import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.getSerializableArgument import com.nextcloud.utils.extensions.isActive import com.nextcloud.utils.extensions.isDialogFragmentReady import com.nextcloud.utils.extensions.lastFragment @@ -136,6 +137,7 @@ import com.owncloud.android.ui.fragment.EmptyListState import com.owncloud.android.ui.fragment.FileDetailFragment import com.owncloud.android.ui.fragment.FileFragment import com.owncloud.android.ui.fragment.GalleryFragment +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState import com.owncloud.android.ui.fragment.GroupfolderListFragment import com.owncloud.android.ui.fragment.OCFileListFragment import com.owncloud.android.ui.fragment.SearchType @@ -2071,8 +2073,14 @@ class FileDisplayActivity : null } + val mediaState = if (type == VirtualFolderType.GALLERY) { + (listOfFilesFragment as? GalleryFragment)?.currentMediaState + } else { + null + } + val showPreview = file.isDown || MimeTypeUtil.isVideo(file) - startImagePreview(file, showPreview, type) + startImagePreview(file, showPreview, type, mediaState) } fun previewFile(file: OCFile, setFabVisible: CompletionCallback?) { @@ -2700,7 +2708,12 @@ class FileDisplayActivity : } } - fun startImagePreview(file: OCFile, showPreview: Boolean, type: VirtualFolderType? = null) { + fun startImagePreview( + file: OCFile, + showPreview: Boolean, + type: VirtualFolderType? = null, + mediaState: MediaState? = null + ) { if (user.isEmpty) { Log_OC.e(TAG, "cannot start image preview") return @@ -2711,6 +2724,7 @@ class FileDisplayActivity : putExtra(EXTRA_LIVE_PHOTO_FILE, file.livePhotoVideo) putExtra(EXTRA_USER, user.get()) type?.let { putExtra(PreviewImageActivity.EXTRA_VIRTUAL_TYPE, it) } + mediaState?.let { putExtra(PreviewImageActivity.EXTRA_MEDIA_STATE, it) } } if (showPreview) { @@ -2952,7 +2966,8 @@ class FileDisplayActivity : startImagePreview( file, true, - virtualType + virtualType, + bundle.getSerializableArgument(PreviewImageActivity.EXTRA_MEDIA_STATE, MediaState::class.java) ) } else { startImagePreview(file, true) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt index c538c7d1ea9e..ae2d2306be15 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt @@ -228,6 +228,9 @@ class GalleryFragment : override fun getCommonAdapter(): CommonOCFileListAdapterInterface? = adapter + val currentMediaState: MediaState? + get() = bottomSheet?.currMediaState + override fun onResume() { super.onResume() diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index bd277ff037dc..740854ffb521 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -14,7 +14,6 @@ import android.content.IntentFilter import android.content.res.Configuration import android.os.Bundle import android.view.MenuItem -import android.view.View import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable @@ -59,6 +58,7 @@ import com.owncloud.android.ui.activity.OnFilesRemovedListener import com.owncloud.android.ui.dialog.SendShareDialog import com.owncloud.android.ui.fragment.FileFragment import com.owncloud.android.ui.fragment.GalleryFragment +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState import com.owncloud.android.ui.preview.model.PreviewImageActivityState import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil @@ -158,23 +158,25 @@ class PreviewImageActivity : private fun applyDisplayCutOutTopPadding() { window.decorView.setOnApplyWindowInsetsListener { view, insets -> - val displayCutout = insets.displayCutout - if (displayCutout != null) { - val safeInsetTop = displayCutout.safeInsetTop - val viewPager = findViewById(R.id.fragmentPager) - viewPager.setPadding( - viewPager.paddingLeft, - safeInsetTop, - viewPager.paddingRight, - viewPager.paddingBottom - ) - viewPager.setBackgroundColor(ContextCompat.getColor(this, R.color.black)) - } - + updatePagerDisplayCutOutPadding(isInPictureInPictureMode, insets.displayCutout?.safeInsetTop ?: 0) view.onApplyWindowInsets(insets) } } + private fun updatePagerDisplayCutOutPadding(inPictureInPictureMode: Boolean, safeInsetTop: Int) { + val pager = viewPager ?: findViewById(R.id.fragmentPager) ?: return + val topPadding = if (inPictureInPictureMode) 0 else safeInsetTop + + pager.setPadding(pager.paddingLeft, topPadding, pager.paddingRight, pager.paddingBottom) + + if (topPadding > 0) { + pager.setBackgroundColor(ContextCompat.getColor(this, R.color.black)) + } + } + + private fun displayCutOutSafeInsetTop(): Int = + ViewCompat.getRootWindowInsets(window.decorView)?.displayCutout?.safeInsetTop ?: 0 + fun toggleActionBarVisibility(hide: Boolean) { if (hide) { supportActionBar?.hide() @@ -193,7 +195,8 @@ class PreviewImageActivity : type, user, storageManager, - preferences + preferences, + intent.getSerializableArgument(EXTRA_MEDIA_STATE, MediaState::class.java) ) } else { val parentFolder = file?.let { storageManager.getFileById(it.parentId) } @@ -502,6 +505,7 @@ class PreviewImageActivity : override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + updatePagerDisplayCutOutPadding(isInPictureInPictureMode, displayCutOutSafeInsetTop()) if (isInPictureInPictureMode) { wasSystemUiVisibleBeforePictureInPicture = isSystemUIVisible @@ -638,6 +642,7 @@ class PreviewImageActivity : companion object { val TAG: String = PreviewImageActivity::class.java.simpleName const val EXTRA_VIRTUAL_TYPE: String = "EXTRA_VIRTUAL_TYPE" + const val EXTRA_MEDIA_STATE: String = "EXTRA_MEDIA_STATE" private const val KEY_WAITING_FOR_BINDER = "WAITING_FOR_BINDER" private const val KEY_SYSTEM_VISIBLE = "TRUE" private const val NO_POSITION = -1 diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index 187a7de23618..36ec5a5d29df 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -22,6 +22,7 @@ import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState import com.owncloud.android.utils.FileSortOrder import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.MimeTypeUtil @@ -67,12 +68,14 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { cachedFragments = SparseArray() } + @Suppress("LongParameterList") constructor( fragmentActivity: FragmentActivity, type: VirtualFolderType?, user: User, storageManager: FileDataStorageManager, - preferences: AppPreferences + preferences: AppPreferences, + mediaState: MediaState? ) : super(fragmentActivity) { requireNotNull(type) { "NULL parent folder" } require(type != VirtualFolderType.NONE) { "NONE virtual folder type" } @@ -81,7 +84,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this@PreviewMediaPagerAdapter.storageManager = storageManager playbackCollection = type.toPlaybackCollection() - mediaFiles = loadVirtualFolderMediaFiles(type, preferences) + mediaFiles = loadVirtualFolderMediaFiles(type, preferences, mediaState) obsoleteFragments = HashSet() obsoletePositions = HashSet() @@ -89,13 +92,26 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { cachedFragments = SparseArray() } - private fun loadVirtualFolderMediaFiles(type: VirtualFolderType, preferences: AppPreferences): MutableList { + private fun loadVirtualFolderMediaFiles( + type: VirtualFolderType, + preferences: AppPreferences, + mediaState: MediaState? + ): MutableList { val source = if (type == VirtualFolderType.GALLERY) { - storageManager.allGalleryItems + val mediaFolderPath = preferences.getLastSelectedMediaFolder() + storageManager.allGalleryItems.filter { it.remotePath.startsWith(mediaFolderPath) } } else { storageManager.getVirtualFolderContent(type, false) } - val mediaFiles = source.filter { MimeTypeUtil.isImageOrVideo(it) }.toMutableList() + val mediaFiles = source + .filter { + when (mediaState) { + MediaState.MEDIA_STATE_PHOTOS_ONLY -> MimeTypeUtil.isImage(it) + MediaState.MEDIA_STATE_VIDEOS_ONLY -> MimeTypeUtil.isVideo(it) + else -> MimeTypeUtil.isImageOrVideo(it) + } + } + .toMutableList() if (type != VirtualFolderType.FAVORITE) { return FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) From bf5400841d9a1b28cf2002a361cd07f77a29c801 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 14:42:32 +0200 Subject: [PATCH 54/69] fix pip controls Signed-off-by: alperozturk96 --- .../client/player/ui/PlayerActivity.kt | 13 ++++++++++++ .../client/player/ui/video/VideoPlayerView.kt | 4 +++- .../ui/preview/PreviewImageActivity.kt | 20 +++++++++++++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 8391ceddadc6..0653dca62b5d 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -69,6 +69,8 @@ class PlayerActivity : private var keepPlaybackAliveOnFinish = false + private var configurationChangedInPictureInPicture = false + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -143,10 +145,16 @@ class PlayerActivity : super.onConfigurationChanged(newConfig) if (isInPictureInPictureMode) { + configurationChangedInPictureInPicture = true (playerView as? VideoPlayerView)?.hideControls() return } + rebuildPlayerViewForCurrentConfiguration() + } + + private fun rebuildPlayerViewForCurrentConfiguration() { + configurationChangedInPictureInPicture = false recreatePlayerView() (playerView as? VideoPlayerView)?.showControls() } @@ -172,6 +180,11 @@ class PlayerActivity : if (!isInPictureInPictureMode && lifecycle.currentState == Lifecycle.State.CREATED) { finish() // Finish the activity if the user closes the PIP window + return + } + + if (!isInPictureInPictureMode && configurationChangedInPictureInPicture) { + rebuildPlayerViewForCurrentConfiguration() } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt index f0bcf4283c4c..652f2b204212 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt @@ -40,7 +40,9 @@ class VideoPlayerView(context: Context) : PlayerView(context) { override fun onStart() { super.onStart() - showControls() + if (!activity.isInPictureInPictureMode) { + showControls() + } } override fun onStop() { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 740854ffb521..2bec2a09d392 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -99,6 +99,7 @@ class PreviewImageActivity : private val pictureInPicture by lazy { VideoPictureInPicture(this, playbackModel, autoEnter = false) } private var wasSystemUiVisibleBeforePictureInPicture = true + private var configurationChangedInPictureInPicture = false private var keepPlaybackOnFinish = false @Inject @@ -498,8 +499,18 @@ class PreviewImageActivity : override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - if (isFinishing || isInPictureInPictureMode) return + if (isFinishing) return + if (isInPictureInPictureMode) { + configurationChangedInPictureInPicture = true + return + } + + rebuildViewPagerForCurrentConfiguration() + } + + private fun rebuildViewPagerForCurrentConfiguration() { + configurationChangedInPictureInPicture = false initViewPager() } @@ -515,7 +526,12 @@ class PreviewImageActivity : toggleActionBarVisibility(!wasSystemUiVisibleBeforePictureInPicture) - if (lifecycle.currentState != Lifecycle.State.CREATED) return + if (lifecycle.currentState != Lifecycle.State.CREATED) { + if (configurationChangedInPictureInPicture) { + rebuildViewPagerForCurrentConfiguration() + } + return + } if (!keepPlaybackOnFinish) { playbackModel.release() From e102fa6a117740e146a279c3e907fa8ede589789 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 14:45:23 +0200 Subject: [PATCH 55/69] fix codacy Signed-off-by: alperozturk96 --- .../main/java/com/nextcloud/client/di/ViewModelModule.kt | 6 ++++++ .../com/nextcloud/client/player/media3/PlaybackModel.kt | 1 + .../client/player/media3/session/MediaSessionCallback.kt | 1 + 3 files changed, 8 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index 4017ed61395a..4e665873eb07 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.ViewModelProvider import com.nextcloud.client.documentscan.DocumentScanViewModel import com.nextcloud.client.etm.EtmViewModel import com.nextcloud.client.logger.ui.LogsViewModel +import com.nextcloud.client.player.ui.PlayerViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel import com.nextcloud.ui.tags.TagManagementViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel @@ -63,6 +64,11 @@ abstract class ViewModelModule { @ViewModelKey(TagManagementViewModel::class) abstract fun tagManagementViewModel(vm: TagManagementViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(PlayerViewModel::class) + abstract fun playerViewModel(vm: PlayerViewModel): ViewModel + @Binds abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index 252458eda141..3fac08089dac 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -140,6 +140,7 @@ class PlaybackModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private suspend fun ListenableFuture.await(): T = suspendCancellableCoroutine { cont -> addListener( { diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt index 447fab5b75be..ffd8d296c599 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -65,6 +65,7 @@ class MediaSessionCallback @Inject constructor( } @OptIn(DelicateCoroutinesApi::class) + @Suppress("TooGenericExceptionCaught") override fun onPlaybackResumption( mediaSession: MediaSession, controller: MediaSession.ControllerInfo, From c1d23005a731d7c05b62cbe893cc120eccff35ae Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 16:53:08 +0200 Subject: [PATCH 56/69] speedup Signed-off-by: alperozturk96 --- .../client/player/media3/PlaybackModel.kt | 68 ++++++--- .../datasource/PlaybackDataSourceFactory.kt | 5 +- .../client/player/ui/PlayerActivity.kt | 21 +-- .../player/ui/video/VideoFileFragment.kt | 13 +- .../ui/preview/PreviewImageActivity.kt | 144 ++++++++---------- .../ui/preview/PreviewMediaFilesLoader.kt | 72 +++++++++ .../ui/preview/PreviewMediaPagerAdapter.kt | 115 ++------------ .../ui/preview/PreviewPlaybackFragment.kt | 12 +- 8 files changed, 225 insertions(+), 225 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFilesLoader.kt diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index 3fac08089dac..02b22784b9c4 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -11,7 +11,6 @@ import android.content.ComponentName import android.content.Context import android.view.SurfaceView import androidx.annotation.OptIn -import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.MediaSession @@ -35,6 +34,7 @@ import com.nextcloud.client.player.util.PlayerUtil.updateMediaItems import com.owncloud.android.datamodel.OCFile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow @@ -82,16 +82,20 @@ class PlaybackModel @Inject constructor( private val controllerListener = object : MediaController.Listener { override fun onDisconnected(controller: MediaController) { controller.removeListener(playerListener) + if (this@PlaybackModel.controller === controller) { + this@PlaybackModel.controller = null + } videoSurfaceView = null invalidateCurrentFiles() - controllerScope?.cancel() + stopFilesFlow() checkProgressPeriodicAction.stop() notifyPlaybackUpdate() } } private var controllerScope: CoroutineScope? = null - private var controller: Player? = null + private var controller: MediaController? = null + private var filesFlowJob: Job? = null private var mediaSession: MediaSession? = null @@ -126,6 +130,12 @@ class PlaybackModel @Inject constructor( } suspend fun start() { + if (controller?.isConnected == true) { + return + } + + releaseController() + videoSurfaceView = null val sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) controller = MediaController.Builder(context, sessionToken) @@ -136,8 +146,26 @@ class PlaybackModel @Inject constructor( addListener(playerListener) setRepeatMode(playbackSettings.repeatMode) shuffleModeEnabled = playbackSettings.isShuffle - controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) } + controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + } + + private fun releaseController() { + stopFilesFlow() + checkProgressPeriodicAction.stop() + invalidateCurrentFiles() + + val current = controller ?: return + controller = null + current.removeListener(playerListener) + current.release() + } + + private fun stopFilesFlow() { + filesFlowJob?.cancel() + filesFlowJob = null + controllerScope?.cancel() + controllerScope = null } @Suppress("TooGenericExceptionCaught") @@ -161,7 +189,8 @@ class PlaybackModel @Inject constructor( } fun setFilesFlow(filesFlow: Flow) { - controllerScope?.launch { + filesFlowJob?.cancel() + filesFlowJob = controllerScope?.launch { filesFlow .catch { notifyPlaybackError(it) @@ -177,21 +206,22 @@ class PlaybackModel @Inject constructor( return } - controller?.let { controller -> - val currentFile = controller.currentMediaItem?.mediaMetadata?.playbackFile - val mediaItems = files.list.map { it.toMediaItem() } - - if (currentFile == null) { - controller.setMediaItems(mediaItems) - } else if (files.list.any { it.id == currentFile.id }) { - controller.updateMediaItems(mediaItems) - } else { - val nextFileIndex = getNextFileIndex(files, currentFile) - controller.setMediaItems(mediaItems, nextFileIndex, 0) - } + val controller = controller ?: return + val mediaItems = files.list.map { it.toMediaItem() } + + if (controller.readMediaIds() == mediaItems.map { it.mediaId }) { + return + } - controller.prepare() + val currentFile = controller.currentMediaItem?.mediaMetadata?.playbackFile + + when { + currentFile == null -> controller.setMediaItems(mediaItems) + files.list.any { it.id == currentFile.id } -> controller.updateMediaItems(mediaItems) + else -> controller.setMediaItems(mediaItems, getNextFileIndex(files, currentFile), 0) } + + controller.prepare() } private fun getNextFileIndex(files: PlaybackFiles, currentFile: PlaybackFile): Int = (files.list + currentFile) @@ -201,7 +231,7 @@ class PlaybackModel @Inject constructor( fun release() { videoSurfaceView = null - controller?.release() + releaseController() mediaSession?.player?.release() mediaSession?.release() mediaSession = null diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt index 40c0af28912f..f0998a79343e 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt @@ -19,12 +19,13 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.network.ClientFactory import com.owncloud.android.MainApp import com.owncloud.android.datamodel.FileDataStorageManager +import dagger.Lazy import javax.inject.Inject @UnstableApi class PlaybackDataSourceFactory @Inject constructor( private val context: Context, - private val cache: Cache, + private val cache: Lazy, private val fileDataStorageManager: FileDataStorageManager, private val clientFactory: ClientFactory, private val accountManager: UserAccountManager @@ -32,7 +33,7 @@ class PlaybackDataSourceFactory @Inject constructor( override fun createDataSource(): DataSource = CacheDataSource.Factory() .setUpstreamDataSourceFactory(createUpstreamDataSourceFactory()) - .setCache(cache) + .setCache(cache.get()) .createDataSource() private fun createUpstreamDataSourceFactory() = DataSource.Factory { diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt index 0653dca62b5d..f56b4e0c8fba 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -69,8 +69,6 @@ class PlayerActivity : private var keepPlaybackAliveOnFinish = false - private var configurationChangedInPictureInPicture = false - override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -144,19 +142,12 @@ class PlayerActivity : override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) + val videoPlayerView = playerView as? VideoPlayerView ?: return if (isInPictureInPictureMode) { - configurationChangedInPictureInPicture = true - (playerView as? VideoPlayerView)?.hideControls() - return + videoPlayerView.hideControls() + } else { + videoPlayerView.showControls() } - - rebuildPlayerViewForCurrentConfiguration() - } - - private fun rebuildPlayerViewForCurrentConfiguration() { - configurationChangedInPictureInPicture = false - recreatePlayerView() - (playerView as? VideoPlayerView)?.showControls() } override fun onUserLeaveHint() { @@ -183,8 +174,8 @@ class PlayerActivity : return } - if (!isInPictureInPictureMode && configurationChangedInPictureInPicture) { - rebuildPlayerViewForCurrentConfiguration() + if (!isInPictureInPictureMode) { + (playerView as? VideoPlayerView)?.showControls() } } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index ab6c3ac7feee..71814d9c2e89 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -52,6 +52,8 @@ class VideoFileFragment : private var _binding: PlayerVideoFileFragmentBinding? = null private val binding get() = checkNotNull(_binding) { "Binding accessed outside of the view lifecycle" } + private var renderedVideoSize: VideoSize? = null + private val file by lazy { requireNotNull(arguments.getPlaybackFile(ARGUMENT_FILE)) { "VideoFileFragment requires a $ARGUMENT_FILE argument" @@ -115,6 +117,7 @@ class VideoFileFragment : return } + renderedVideoSize = null binding.surfaceView.isVisible = false if (currentItemState == null) { playerModel.setVideoSurfaceView(null) @@ -125,11 +128,15 @@ class VideoFileFragment : if (ownsPlayback(binding.surfaceView)) { playerModel.setVideoSurfaceView(binding.surfaceView) } + + val size = videoSize ?: renderedVideoSize + renderedVideoSize = size + binding.surfaceView.isVisible = true - binding.surfaceView.alpha = if (videoSize == null) SURFACE_ALPHA_HIDDEN else SURFACE_ALPHA_VISIBLE + binding.surfaceView.alpha = if (size == null) SURFACE_ALPHA_HIDDEN else SURFACE_ALPHA_VISIBLE - if (videoSize == null) return + if (size == null) return - binding.surfaceView.applyVideoSize(videoSize) + binding.surfaceView.applyVideoSize(size) } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 2bec2a09d392..421323b07fd4 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -23,6 +23,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback @@ -34,6 +35,8 @@ import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.download.SendShareDownloader +import com.nextcloud.client.player.model.file.PlaybackCollection +import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.player.ui.MediaNavigator import com.nextcloud.client.player.ui.VideoPictureInPicture import com.nextcloud.client.preferences.AppPreferences @@ -63,9 +66,9 @@ import com.owncloud.android.ui.preview.model.PreviewImageActivityState import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import edu.umd.cs.findbugs.annotations.SuppressFBWarnings -import java.io.Serializable +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.math.max import kotlin.math.min /** @@ -84,6 +87,13 @@ class PreviewImageActivity : private var viewPager: ViewPager2? = null private var previewMediaPagerAdapter: PreviewMediaPagerAdapter? = null private var savedPosition: Int? = null + private var initViewPagerJob: Job? = null + + private val onPageChangeCallback = object : OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + selectPage(position) + } + } private val sendShareDownloader by lazy { SendShareDownloader(this, localBroadcastManager) } @@ -99,7 +109,6 @@ class PreviewImageActivity : private val pictureInPicture by lazy { VideoPictureInPicture(this, playbackModel, autoEnter = false) } private var wasSystemUiVisibleBeforePictureInPicture = true - private var configurationChangedInPictureInPicture = false private var keepPlaybackOnFinish = false @Inject @@ -187,56 +196,47 @@ class PreviewImageActivity : } private fun initViewPager(user: User) { - val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, Serializable::class.java) - if (virtualFolderType != null && virtualFolderType !== VirtualFolderType.NONE) { - val type = virtualFolderType as VirtualFolderType - - previewMediaPagerAdapter = PreviewMediaPagerAdapter( - this, - type, - user, - storageManager, - preferences, - intent.getSerializableArgument(EXTRA_MEDIA_STATE, MediaState::class.java) - ) - } else { - val parentFolder = file?.let { storageManager.getFileById(it.parentId) } - ?: storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) - - previewMediaPagerAdapter = PreviewMediaPagerAdapter( - this, - livePhotoFile, - parentFolder, - user, - storageManager, - MainApp.isOnlyOnDevice(), - preferences - ) + initViewPagerJob?.cancel() + initViewPagerJob = lifecycleScope.launch { + showViewPager(user, loadMediaFiles()) } + } - viewPager = findViewById(R.id.fragmentPager) + private suspend fun loadMediaFiles(): List { + val loader = PreviewMediaFilesLoader(storageManager, preferences) + val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, VirtualFolderType::class.java) - var position = if (savedPosition != - null - ) { - savedPosition - } else { - file?.let { previewMediaPagerAdapter?.getFilePosition(it) } + if (virtualFolderType != null && virtualFolderType !== VirtualFolderType.NONE) { + val mediaState = intent.getSerializableArgument(EXTRA_MEDIA_STATE, MediaState::class.java) + return loader.forVirtualFolder(virtualFolderType, mediaState) } - position = position?.toDouble()?.let { max(it, 0.0).toInt() } + + return loader.forFolder(file?.parentId, MainApp.isOnlyOnDevice()) + } + + private fun showViewPager(user: User, mediaFiles: List) { + val adapter = PreviewMediaPagerAdapter( + this, + mediaFiles, + playbackCollection(), + user, + livePhotoFile + ) + previewMediaPagerAdapter = adapter + + val position = (savedPosition ?: file?.let { adapter.getFilePosition(it) })?.coerceAtLeast(0) if (savedPosition == null) { - previewMediaPagerAdapter?.autoplayFileId = file?.fileId + adapter.autoplayFileId = file?.fileId } - viewPager?.adapter = previewMediaPagerAdapter - viewPager?.registerOnPageChangeCallback(object : OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - selectPage(position) - } - }) + val pager = findViewById(R.id.fragmentPager) + viewPager = pager + pager.adapter = adapter + pager.unregisterOnPageChangeCallback(onPageChangeCallback) + pager.registerOnPageChangeCallback(onPageChangeCallback) if (position != null) { - viewPager?.setCurrentItem(position, false) + pager.setCurrentItem(position, false) } if (position == 0 && file?.isDown == false) { @@ -246,6 +246,14 @@ class PreviewImageActivity : } } + private fun playbackCollection(): PlaybackCollection { + val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, VirtualFolderType::class.java) + return virtualFolderType + ?.takeIf { it !== VirtualFolderType.NONE } + ?.toPlaybackCollection() + ?: PlaybackCollection.FOLDER + } + override fun onFilesRemoved() { initViewPager() } @@ -376,21 +384,17 @@ class PreviewImageActivity : } } - private fun setDownloadedItem() { - savedPosition?.let { position -> + private fun setDownloadedItem(downloadedFile: OCFile?) { + val position = savedPosition ?: return + val adapter = previewMediaPagerAdapter ?: return + val file = downloadedFile ?: return - previewMediaPagerAdapter?.run { - file?.let { - updateFile(position, it) - notifyItemChanged(position) - } - } - - if (user.isPresent) { - initViewPager(user.get()) - viewPager?.currentItem = position - } + if (adapter.getFileAt(position)?.fileId != file.fileId) { + return } + + adapter.updateFile(position, file) + adapter.notifyItemChanged(position) } private fun selectPageOnDownload() { @@ -497,23 +501,6 @@ class PreviewImageActivity : fun enterPictureInPicture(): Boolean = viewPager?.let { pictureInPicture.enter(it) } == true - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - if (isFinishing) return - - if (isInPictureInPictureMode) { - configurationChangedInPictureInPicture = true - return - } - - rebuildViewPagerForCurrentConfiguration() - } - - private fun rebuildViewPagerForCurrentConfiguration() { - configurationChangedInPictureInPicture = false - initViewPager() - } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) updatePagerDisplayCutOutPadding(isInPictureInPictureMode, displayCutOutSafeInsetTop()) @@ -526,12 +513,7 @@ class PreviewImageActivity : toggleActionBarVisibility(!wasSystemUiVisibleBeforePictureInPicture) - if (lifecycle.currentState != Lifecycle.State.CREATED) { - if (configurationChangedInPictureInPicture) { - rebuildViewPagerForCurrentConfiguration() - } - return - } + if (lifecycle.currentState != Lifecycle.State.CREATED) return if (!keepPlaybackOnFinish) { playbackModel.release() @@ -598,7 +580,7 @@ class PreviewImageActivity : if (screenState == PreviewImageActivityState.Edit) { onImageDownloadComplete(file) } else { - setDownloadedItem() + setDownloadedItem(file) } } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFilesLoader.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFilesLoader.kt new file mode 100644 index 000000000000..eb64e35fa5e1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFilesLoader.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.preview + +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.VirtualFolderType +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeTypeUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class PreviewMediaFilesLoader( + private val storageManager: FileDataStorageManager, + private val preferences: AppPreferences +) { + + suspend fun forFolder(parentFolderId: Long?, onlyOnDevice: Boolean): List = withContext(Dispatchers.IO) { + val parentFolder = parentFolderId?.let { storageManager.getFileById(it) } + ?: storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + ?: return@withContext emptyList() + + val mediaFiles = storageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice).toMutableList() + + preferences.getSortOrderByFolder(parentFolder).sortCloudFiles( + mediaFiles, + preferences.isSortFoldersBeforeFiles(), + preferences.isSortFavoritesFirst() + ) + } + + suspend fun forVirtualFolder(type: VirtualFolderType, mediaState: MediaState?): List = + withContext(Dispatchers.IO) { + val mediaFiles = readVirtualFolder(type, mediaState) + + if (type != VirtualFolderType.FAVORITE) { + return@withContext FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) + } + + preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView).sortCloudFiles( + mediaFiles, + preferences.isSortFoldersBeforeFiles(), + preferences.isSortFavoritesFirst() + ) + } + + private fun readVirtualFolder(type: VirtualFolderType, mediaState: MediaState?): MutableList { + val source = if (type == VirtualFolderType.GALLERY) { + val mediaFolderPath = preferences.getLastSelectedMediaFolder() + storageManager.allGalleryItems.filter { it.remotePath.startsWith(mediaFolderPath) } + } else { + storageManager.getVirtualFolderContent(type, false) + } + + return source + .filter { + when (mediaState) { + MediaState.MEDIA_STATE_PHOTOS_ONLY -> MimeTypeUtil.isImage(it) + MediaState.MEDIA_STATE_VIDEOS_ONLY -> MimeTypeUtil.isVideo(it) + else -> MimeTypeUtil.isImageOrVideo(it) + } + } + .toMutableList() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index 36ec5a5d29df..5b96dee8ec1e 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -16,114 +16,25 @@ import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import com.nextcloud.client.account.User import com.nextcloud.client.player.model.file.PlaybackCollection -import com.nextcloud.client.player.model.file.toPlaybackCollection -import com.nextcloud.client.preferences.AppPreferences -import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState -import com.owncloud.android.utils.FileSortOrder -import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.MimeTypeUtil -class PreviewMediaPagerAdapter : FragmentStateAdapter { - var autoplayFileId: Long? = null - private var selectedFile: OCFile? = null - private var mediaFiles: MutableList = mutableListOf() - private val user: User - private val obsoleteFragments: MutableSet - private val obsoletePositions: MutableSet - private val downloadErrors: MutableSet - private val storageManager: FileDataStorageManager - private val cachedFragments: SparseArray - private val playbackCollection: PlaybackCollection - - @Suppress("LongParameterList") - constructor( - fragmentActivity: FragmentActivity, - selectedFile: OCFile?, - parentFolder: OCFile?, - user: User, - storageManager: FileDataStorageManager, - onlyOnDevice: Boolean, - preferences: AppPreferences - ) : super(fragmentActivity) { - requireNotNull(parentFolder) { "NULL parent folder" } - - this.user = user - this.selectedFile = selectedFile - this@PreviewMediaPagerAdapter.storageManager = storageManager - playbackCollection = PlaybackCollection.FOLDER - mediaFiles = storageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) - - val sortOrder = preferences.getSortOrderByFolder(parentFolder) - val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() - val favoritesFirst = preferences.isSortFavoritesFirst() - mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) - - obsoleteFragments = HashSet() - obsoletePositions = HashSet() - downloadErrors = HashSet() - cachedFragments = SparseArray() - } +class PreviewMediaPagerAdapter( + fragmentActivity: FragmentActivity, + files: List, + private val playbackCollection: PlaybackCollection, + private val user: User, + private val selectedFile: OCFile? = null +) : FragmentStateAdapter(fragmentActivity) { - @Suppress("LongParameterList") - constructor( - fragmentActivity: FragmentActivity, - type: VirtualFolderType?, - user: User, - storageManager: FileDataStorageManager, - preferences: AppPreferences, - mediaState: MediaState? - ) : super(fragmentActivity) { - requireNotNull(type) { "NULL parent folder" } - require(type != VirtualFolderType.NONE) { "NONE virtual folder type" } - - this.user = user - this@PreviewMediaPagerAdapter.storageManager = storageManager - playbackCollection = type.toPlaybackCollection() - - mediaFiles = loadVirtualFolderMediaFiles(type, preferences, mediaState) - - obsoleteFragments = HashSet() - obsoletePositions = HashSet() - downloadErrors = HashSet() - cachedFragments = SparseArray() - } - - private fun loadVirtualFolderMediaFiles( - type: VirtualFolderType, - preferences: AppPreferences, - mediaState: MediaState? - ): MutableList { - val source = if (type == VirtualFolderType.GALLERY) { - val mediaFolderPath = preferences.getLastSelectedMediaFolder() - storageManager.allGalleryItems.filter { it.remotePath.startsWith(mediaFolderPath) } - } else { - storageManager.getVirtualFolderContent(type, false) - } - val mediaFiles = source - .filter { - when (mediaState) { - MediaState.MEDIA_STATE_PHOTOS_ONLY -> MimeTypeUtil.isImage(it) - MediaState.MEDIA_STATE_VIDEOS_ONLY -> MimeTypeUtil.isVideo(it) - else -> MimeTypeUtil.isImageOrVideo(it) - } - } - .toMutableList() - - if (type != VirtualFolderType.FAVORITE) { - return FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) - } + var autoplayFileId: Long? = null - val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) - return sortOrder.sortCloudFiles( - mediaFiles, - preferences.isSortFoldersBeforeFiles(), - preferences.isSortFavoritesFirst() - ) - } + private val mediaFiles: MutableList = files.toMutableList() + private val obsoleteFragments: MutableSet = HashSet() + private val obsoletePositions: MutableSet = HashSet() + private val downloadErrors: MutableSet = HashSet() + private val cachedFragments: SparseArray = SparseArray() fun delete(position: Int) { if (position < 0 || position >= mediaFiles.size) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index ff75b2e5de3f..577e14af9649 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -80,6 +80,7 @@ class PreviewPlaybackFragment : private var playbackCollection = PlaybackCollection.FOLDER private var autoplay: Boolean = false private var wasCurrentItem = false + private var renderedVideoSize: VideoSize? = null private var pictureInPictureCallback: OnBackPressedCallback? = null @@ -200,6 +201,7 @@ class PreviewPlaybackFragment : val playbackMovedOn = wasCurrentItem && ownsPlayback(binding.surfaceView) wasCurrentItem = false + renderedVideoSize = null binding.surfaceView.visibility = View.GONE if (playbackMovedOn) { showPageOfCurrentItem(state) @@ -228,11 +230,15 @@ class PreviewPlaybackFragment : if (ownsPlayback(binding.surfaceView)) { playbackModel.setVideoSurfaceView(binding.surfaceView) } + + val size = videoSize ?: renderedVideoSize + renderedVideoSize = size + binding.surfaceView.visibility = View.VISIBLE - binding.surfaceView.alpha = if (videoSize != null) SURFACE_ALPHA_VISIBLE else SURFACE_ALPHA_HIDDEN + binding.surfaceView.alpha = if (size != null) SURFACE_ALPHA_VISIBLE else SURFACE_ALPHA_HIDDEN - if (videoSize != null) { - binding.surfaceView.applyVideoSize(videoSize) + if (size != null) { + binding.surfaceView.applyVideoSize(size) } } } From b4b8614c331addeed06f06ead5f8dae9a1b93b46 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 3 Sep 2026 10:03:40 +0200 Subject: [PATCH 57/69] fix leaks, speed-up load Signed-off-by: alperozturk96 --- .../nextcloud/client/database/dao/FileDao.kt | 2 +- .../client/player/media3/PlaybackModel.kt | 11 +++- .../client/player/media3/PlaybackService.kt | 38 +++++++++---- .../session/MediaSessionBitmapLoader.kt | 4 +- .../client/player/model/ThumbnailLoader.kt | 36 +++++++------ .../player/ui/PlayerProgressIndicator.kt | 11 ++-- .../player/ui/audio/AudioFileFragment.kt | 2 +- .../player/ui/control/PlayerControlView.kt | 1 + .../player/ui/video/VideoFileFragment.kt | 3 +- .../client/player/util/PlayerUtil.kt | 36 ++++++------- .../java/com/nextcloud/utils/OCFileUtils.kt | 17 +++--- .../utils/extensions/FileExtensions.kt | 9 +++- .../utils/extensions/OCFileExtensions.kt | 2 +- .../utils/thumbnail/FileThumbnailGenerator.kt | 39 ++++++++------ .../utils/thumbnail/ThumbnailMemoryCache.kt | 42 +++++++++++++++ .../datamodel/ThumbnailsCacheManager.java | 24 ++++++++- .../android/ui/adapter/OCFileListDelegate.kt | 53 ++++++++++++++----- .../ui/fragment/ExtendedListFragment.kt | 14 +++++ .../android/ui/fragment/GalleryFragment.kt | 2 + .../ui/fragment/albums/AlbumItemsFragment.kt | 4 ++ .../ui/preview/PreviewImageActivity.kt | 11 ++-- .../ui/preview/PreviewPlaybackFragment.kt | 10 +++- 22 files changed, 266 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailMemoryCache.kt diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt index 1f38173f2bb3..ca2154a2dc7a 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -71,7 +71,7 @@ interface FileDao { " AND file_owner = :fileOwner" + " AND path LIKE :pathPrefix || '%'" + " AND (:mimeFilter IS NULL OR content_type LIKE :mimeFilter)" + - " ORDER BY modified DESC" + + " ORDER BY modified DESC, ${ProviderTableMeta._ID} DESC" + " LIMIT :limit OFFSET :offset" ) suspend fun getGalleryItemsPageSuspended( diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index 02b22784b9c4..df05320b990d 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -206,7 +206,10 @@ class PlaybackModel @Inject constructor( return } - val controller = controller ?: return + controller?.let { applyFiles(it, files) } + } + + private fun applyFiles(controller: MediaController, files: PlaybackFiles) { val mediaItems = files.list.map { it.toMediaItem() } if (controller.readMediaIds() == mediaItems.map { it.mediaId }) { @@ -237,6 +240,12 @@ class PlaybackModel @Inject constructor( mediaSession = null } + fun clearVideoSurfaceView(surfaceView: SurfaceView) { + if (videoSurfaceView === surfaceView) { + setVideoSurfaceView(null) + } + } + fun setVideoSurfaceView(surfaceView: SurfaceView?) { if (videoSurfaceView === surfaceView) { return diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt index 6b2536aaadce..b84e1e2ba14f 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt @@ -25,11 +25,17 @@ import javax.inject.Inject @UnstableApi class PlaybackService : MediaSessionService() { + companion object { + private const val SESSION_ACTIVITY_REQUEST_CODE = 1 + } + @Inject lateinit var playbackModel: PlaybackModel private var bindingCount: Int = 0 + private var sessionActivityFileType: PlaybackFileType? = null + override fun onCreate() { super.onCreate() AndroidInjection.inject(this) @@ -39,7 +45,7 @@ class PlaybackService : MediaSessionService() { override fun onGetSession(controllerInfo: ControllerInfo): MediaSession? = playbackModel.getMediaSession() override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - createSessionActivity(session.player.currentMediaItem)?.let(session::setSessionActivity) + updateSessionActivity(session) super.onUpdateNotification(session, startInForegroundRequired) } @@ -70,19 +76,29 @@ class PlaybackService : MediaSessionService() { super.onDestroy() } - private fun createSessionActivity(currentMediaItem: MediaItem?): PendingIntent? { - val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null - val fileType = PlaybackFileType.entries - .firstOrNull { currentFile.mimeType.startsWith(it.value, ignoreCase = true) } - ?: throw IllegalArgumentException("Unsupported file type: ${currentFile.mimeType}") + private fun updateSessionActivity(session: MediaSession) { + val fileType = session.player.currentMediaItem.playbackFileType() ?: return + if (fileType == sessionActivityFileType) { + return + } - val intent = PlayerActivity.createIntent(this, fileType) - val requestCode = System.currentTimeMillis().toInt() + sessionActivityFileType = fileType + session.setSessionActivity(createSessionActivity(fileType)) + } + + private fun MediaItem?.playbackFileType(): PlaybackFileType? { + val mimeType = this?.mediaMetadata?.playbackFile?.mimeType ?: return null + return PlaybackFileType.entries.firstOrNull { mimeType.startsWith(it.value, ignoreCase = true) } + } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) + private fun createSessionActivity(fileType: PlaybackFileType): PendingIntent { + val intent = PlayerActivity.createIntent(this, fileType) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { - PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT) + PendingIntent.FLAG_UPDATE_CURRENT } + + return PendingIntent.getActivity(this, SESSION_ACTIVITY_REQUEST_CODE, intent, flags) } } diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt index 188faef18b5d..a211bf4d62ee 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -82,14 +82,14 @@ class MediaSessionBitmapLoader @Inject constructor( private fun getBitmapFromMetadata(metadata: MediaMetadata, fileId: String?): Bitmap? { val model = metadata.artworkData ?: metadata.artworkUri ?: return null return runCatching { - thumbnailLoader.load(context, model, fileId, thumbnailSize, thumbnailSize).get() + thumbnailLoader.load(model, fileId, thumbnailSize, thumbnailSize).get() }.getOrElse { null } } private fun getBitmapForFile(file: PlaybackFile): Bitmap? = runCatching { - thumbnailLoader.load(context, file, thumbnailSize, thumbnailSize).get() + thumbnailLoader.load(file, thumbnailSize, thumbnailSize).get() }.getOrElse { null } diff --git a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt index 74b2ebeb0c23..7ae944daf5c5 100644 --- a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt +++ b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt @@ -26,30 +26,34 @@ import java.util.concurrent.Future import javax.inject.Inject import kotlin.coroutines.resume -class ThumbnailLoader @Inject constructor(clientFactory: ClientFactory, userAccountManager: UserAccountManager) { +class ThumbnailLoader @Inject constructor( + context: Context, + clientFactory: ClientFactory, + userAccountManager: UserAccountManager +) { + private val appContext = context.applicationContext private val client by lazy { clientFactory.createNextcloudClient(userAccountManager.user) } - suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? = - withContext(Dispatchers.IO) { - suspendCancellableCoroutine { continuation -> - runCatching { - val future = load(context, file, width, height) - continuation.invokeOnCancellation { future.cancel(true) } - continuation.resume(future.get()) - }.onFailure { - if (it is CancellationException) throw it - continuation.resume(null) - } + suspend fun await(file: PlaybackFile, width: Int, height: Int): Bitmap? = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + runCatching { + val future = load(file, width, height) + continuation.invokeOnCancellation { future.cancel(true) } + continuation.resume(future.get()) + }.onFailure { + if (it is CancellationException) throw it + continuation.resume(null) } } + } - fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future { + fun load(file: PlaybackFile, width: Int, height: Int): Future { val url = createUrl(file, width, height) - return load(context, url, file.id, width, height) + return load(url, file.id, width, height) } - fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future = Glide - .with(context) + fun load(model: Any, fileId: String?, width: Int, height: Int): Future = Glide + .with(appContext) .asBitmap() .load(model) .signature(ObjectKey(fileId ?: model.toString())) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt index 65d18a0fbcf4..947e9b04c46a 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt @@ -58,8 +58,7 @@ class PlayerProgressIndicator @JvmOverloads constructor( } override fun onPlaybackUpdate(state: PlaybackState) { - val itemState = state.currentItemState - render(itemState) + render(state.currentItemState) } fun setFile(file: OCFile) { @@ -68,8 +67,12 @@ class PlayerProgressIndicator @JvmOverloads constructor( } private fun renderCurrentState() { - val itemState = playbackModel.state?.currentItemState - render(itemState) + if (playbackFile == null) { + visibility = GONE + return + } + + render(playbackModel.state?.currentItemState) } private fun render(itemState: PlaybackItemState?) { diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt index fab8e53ddd8e..f567a15cd494 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -120,7 +120,7 @@ open class AudioFileFragment : private fun loadFileThumbnail(): Job = viewLifecycleOwner.lifecycleScope.launch { val context = context ?: return@launch val thumbnailSize = context.resources.getDimensionPixelSize(R.dimen.player_album_cover_size) - val thumbnail = thumbnailLoader.await(context, file, thumbnailSize, thumbnailSize) ?: return@launch + val thumbnail = thumbnailLoader.await(file, thumbnailSize, thumbnailSize) ?: return@launch val albumCover = _binding?.albumCover ?: return@launch albumCover.setImageBitmap(thumbnail) isFileThumbnailLoaded = true diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index 81c0129cd702..662ebe09b80e 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -93,6 +93,7 @@ class PlayerControlView @JvmOverloads constructor( viewScope?.cancel() viewScope = null } + navigator = null super.onDetachedFromWindow() } diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt index 71814d9c2e89..0255165604f4 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -93,6 +93,7 @@ class VideoFileFragment : } override fun onDestroyView() { + _binding?.let { playerModel.clearVideoSurfaceView(it.surfaceView) } _binding = null super.onDestroyView() } @@ -104,7 +105,7 @@ class VideoFileFragment : private fun loadFileThumbnail() = viewLifecycleOwner.lifecycleScope.launch { val context = context ?: return@launch val thumbnailSize = context.resources.getDimensionPixelSize(R.dimen.player_album_cover_size) - val thumbnail = thumbnailLoader.await(context, file, thumbnailSize, thumbnailSize) ?: return@launch + val thumbnail = thumbnailLoader.await(file, thumbnailSize, thumbnailSize) ?: return@launch _binding?.thumbnail?.setImageBitmap(thumbnail) } diff --git a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt index eee98039bc3f..e8ff924c2aeb 100644 --- a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt +++ b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt @@ -16,6 +16,7 @@ import android.database.ContentObserver import android.graphics.Rect import android.net.Uri import android.os.Bundle +import android.util.LruCache import android.os.Handler import android.os.Looper import android.os.Process @@ -51,9 +52,12 @@ object PlayerUtil { private const val NO_INDEX = -1 private const val UNKNOWN_CONTENT_LENGTH = -1L private const val SECOND_IN_MILLISECONDS = 1000L + private const val PLAYBACK_FILE_CACHE_SIZE = 512 private val playbackJson = Json { ignoreUnknownKeys = true } + private val playbackFileCache = LruCache(PLAYBACK_FILE_CACHE_SIZE) + fun PlaybackFile.toMediaItem(): MediaItem = MediaItem .Builder() .setMediaId(id) @@ -89,25 +93,17 @@ object PlayerUtil { ?.let { currentMediaId -> newMediaItems.indexOfFirst { it.mediaId == currentMediaId } } ?.takeIf { it >= 0 } - if (oldCurrentMediaItemIndex != null && newCurrentMediaItemIndex != null) { - if (oldCurrentMediaItemIndex < mediaItemCount - 1) { - removeMediaItems(oldCurrentMediaItemIndex + 1, mediaItemCount) - } - if (newCurrentMediaItemIndex < newMediaItems.size - 1) { - val itemsToAdd = newMediaItems.subList(newCurrentMediaItemIndex + 1, newMediaItems.size) - addMediaItems(itemsToAdd) - } - if (oldCurrentMediaItemIndex > 0) { - removeMediaItems(0, oldCurrentMediaItemIndex) - } - if (newCurrentMediaItemIndex > 0) { - val itemsToAdd = newMediaItems.subList(0, newCurrentMediaItemIndex) - addMediaItems(0, itemsToAdd) - } - replaceMediaItem(newCurrentMediaItemIndex, newMediaItems[newCurrentMediaItemIndex]) - } else { + if (oldCurrentMediaItemIndex == null || newCurrentMediaItemIndex == null) { setMediaItems(newMediaItems) + return } + + replaceMediaItems( + oldCurrentMediaItemIndex + 1, + mediaItemCount, + newMediaItems.subList(newCurrentMediaItemIndex + 1, newMediaItems.size) + ) + replaceMediaItems(0, oldCurrentMediaItemIndex, newMediaItems.subList(0, newCurrentMediaItemIndex)) } fun Player.setRepeatMode(mode: RepeatMode) { @@ -222,7 +218,11 @@ object PlayerUtil { fun Bundle?.getPlaybackFile(key: String): PlaybackFile? { val encoded = this?.getString(key) ?: return null - return runCatching { playbackJson.decodeFromString(encoded) }.getOrNull() + + return playbackFileCache[encoded] + ?: runCatching { playbackJson.decodeFromString(encoded) } + .getOrNull() + ?.also { playbackFileCache.put(encoded, it) } } fun ContentResolver.observeContentChanges(uri: Uri, notifyForDescendants: Boolean) = callbackFlow { diff --git a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt index d16ea8eef159..d7bcfb1d40e9 100644 --- a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt +++ b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt @@ -7,6 +7,7 @@ package com.nextcloud.utils import android.graphics.Color +import android.util.LruCache import android.graphics.drawable.BitmapDrawable import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap @@ -23,31 +24,31 @@ import com.owncloud.android.utils.MimeTypeUtil @Suppress("TooGenericExceptionCaught", "ReturnCount") object OCFileUtils { private const val TAG = "OCFileUtils" + private const val IMAGE_SIZE_CACHE_ENTRIES = 2048 + + private val imageSizes = LruCache>(IMAGE_SIZE_CACHE_ENTRIES) fun getImageSize(ocFile: OCFile, defaultThumbnailSize: Float): Pair { val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1) val fallbackPair = fallback to fallback - try { - Log_OC.d(TAG, "Getting image size for: ${ocFile.fileName}") + imageSizes.get(ocFile.fileId)?.let { return it } + try { // Server-provided ocFile.imageDimension?.let { dim -> val w = dim.width.toInt().coerceAtLeast(1) val h = dim.height.toInt().coerceAtLeast(1) - Log_OC.d(TAG, "Using server-provided imageDimension: $w x $h") - return w to h + return (w to h).also { imageSizes.put(ocFile.fileId, it) } } // Local file val path = ocFile.storagePath if (!path.isNullOrEmpty() && ocFile.exists()) { - path.getExifSize()?.let { return it } - path.getBitmapSize()?.let { return it } + path.getExifSize()?.let { return it.also { size -> imageSizes.put(ocFile.fileId, size) } } + path.getBitmapSize()?.let { return it.also { size -> imageSizes.put(ocFile.fileId, size) } } } - // 3 Fallback - Log_OC.d(TAG, "Fallback to default size: $fallback x $fallback") return fallbackPair } catch (e: Exception) { Log_OC.e(TAG, "Error getting image size for ${ocFile.fileName}", e) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt index f1a64c7b18c6..138af0d0060c 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -25,7 +25,12 @@ fun OCFile?.logFileSize(tag: String) { Log_OC.d(tag, "onSaveInstanceState: $size, raw byte $rawByte") } -fun ServerFileInterface.getThumbnailKeys(): List = listOf(getBigThumbnailKey(), getSmallThumbnailKey()) +fun ServerFileInterface.getThumbnailKeys(): List = listOf( + getBigThumbnailKey(), + getSmallThumbnailKey(), + videoOverlayKey(getBigThumbnailKey()), + videoOverlayKey(getSmallThumbnailKey()) +) fun ServerFileInterface.getBigThumbnail(): Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(getBigThumbnailKey()) @@ -36,6 +41,8 @@ fun ServerFileInterface.getSmallThumbnail(): Bitmap? = fun ServerFileInterface.getSmallThumbnailKey(): String = ThumbnailsCacheManager.PREFIX_THUMBNAIL + remoteId +fun videoOverlayKey(thumbnailKey: String): String = ThumbnailsCacheManager.PREFIX_VIDEO_OVERLAY + thumbnailKey + fun File?.getSmallThumbnail(): Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(getSmallThumbnailKey()) fun File?.getSmallThumbnailKey(): String = ThumbnailsCacheManager.PREFIX_THUMBNAIL + hashCode() diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt index ae8b84b1efdc..464c8805ecfc 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt @@ -60,7 +60,7 @@ fun List.toGalleryItems(columns: Int, defaultSize: Int): List + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.thumbnail + +import android.graphics.Bitmap +import android.util.LruCache + +private const val BYTES_PER_KILOBYTE = 1024 +private const val HEAP_FRACTION = 8 + +object ThumbnailMemoryCache { + + private val cache = object : LruCache(maxSizeInKilobytes()) { + override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount / BYTES_PER_KILOBYTE + } + + fun get(key: String): Bitmap? = cache.get(key)?.takeUnless { it.isRecycled } + + fun put(key: String, bitmap: Bitmap) { + if (bitmap.isRecycled) { + return + } + + cache.put(key, bitmap) + } + + fun remove(key: String) { + cache.remove(key) + } + + fun clear() { + cache.evictAll() + } + + private fun maxSizeInKilobytes(): Int = + (Runtime.getRuntime().maxMemory() / BYTES_PER_KILOBYTE / HEAP_FRACTION).toInt() +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index 326817a9a4ac..1328dd5f7d91 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -39,6 +39,7 @@ import com.nextcloud.client.account.User; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.BitmapExtensionsKt; +import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.OCFileExtensionsKt; import com.nextcloud.utils.extensions.OwnCloudClientExtensionsKt; @@ -98,6 +99,9 @@ public final class ThumbnailsCacheManager { /** Cache key prefix {@code }; square, sized from {@link #getThumbnailDimension()}. */ public static final String PREFIX_THUMBNAIL = "t"; + /** Memory-only cache key prefix {@code } for thumbnails with the video play overlay. */ + public static final String PREFIX_VIDEO_OVERLAY = "o"; + private static final String TAG = ThumbnailsCacheManager.class.getSimpleName(); private static final String PNG_MIMETYPE = "image/png"; private static final String CACHE_FOLDER = "thumbnailCache"; @@ -223,12 +227,18 @@ public static void removeFromCache(@Nullable OCFile file) { return; } + List keys = FileExtensionsKt.getThumbnailKeys(file); + + for (String key : keys) { + ThumbnailMemoryCache.INSTANCE.remove(key); + } + synchronized (mThumbnailsDiskCacheLock) { if (mThumbnailCache == null) { return; } - for (String key: FileExtensionsKt.getThumbnailKeys(file)) { + for (String key : keys) { mThumbnailCache.removeKey(key); } } @@ -265,6 +275,7 @@ public static void addBitmapToCache(String key, Bitmap bitmap) { } mThumbnailCache.put(key, bitmap); + ThumbnailMemoryCache.INSTANCE.put(key, bitmap); } } @@ -290,6 +301,11 @@ public static Bitmap getScaledBitmapFromDiskCache(String key, int width, int hei } public static Bitmap getBitmapFromDiskCache(String key) { + Bitmap fromMemory = ThumbnailMemoryCache.INSTANCE.get(key); + if (fromMemory != null) { + return fromMemory; + } + synchronized (mThumbnailsDiskCacheLock) { // Wait while disk cache is started from background thread while (mThumbnailCacheStarting) { @@ -300,7 +316,11 @@ public static Bitmap getBitmapFromDiskCache(String key) { } } if (mThumbnailCache != null) { - return mThumbnailCache.getBitmap(key); + Bitmap fromDisk = mThumbnailCache.getBitmap(key); + if (fromDisk != null) { + ThumbnailMemoryCache.INSTANCE.put(key, fromDisk); + } + return fromDisk; } } return null; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt index dd8dd77020fd..edc14e4a4f37 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt @@ -8,6 +8,7 @@ package com.owncloud.android.ui.adapter import android.content.Context +import android.graphics.Bitmap import android.view.View import android.widget.ImageView import androidx.core.content.ContextCompat @@ -19,14 +20,16 @@ import com.nextcloud.client.jobs.gallery.GalleryImageGenerationJob import com.nextcloud.client.jobs.gallery.GalleryImageGenerationListener import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.utils.OCFileUtils -import com.nextcloud.utils.extensions.getBigThumbnail -import com.nextcloud.utils.extensions.getSmallThumbnail +import com.nextcloud.utils.extensions.getBigThumbnailKey +import com.nextcloud.utils.extensions.getSmallThumbnailKey import com.nextcloud.utils.extensions.makeRounded import com.nextcloud.utils.extensions.setVisibleIf import com.nextcloud.utils.extensions.stopShimmer import com.nextcloud.utils.mdm.MDMConfig +import com.nextcloud.utils.extensions.videoOverlayKey import com.nextcloud.utils.thumbnail.ThumbnailArguments import com.nextcloud.utils.thumbnail.ThumbnailGenerator +import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile @@ -109,24 +112,20 @@ class OCFileListDelegate( galleryRowHolder: GalleryRowHolder, imageDimension: Pair ) { - // Cancel previous job for this ImageView GalleryImageGenerationJob.cancelPreviousJob(imageView) imageView.tag = file.fileId - // set placeholder before async job - val cachedBitmap = file.getBigThumbnail() ?: file.getSmallThumbnail() - if (cachedBitmap != null) { - val overlay = if (MimeTypeUtil.isVideo(file)) { - ThumbnailsCacheManager.addVideoOverlay(cachedBitmap, context) - } else { - cachedBitmap - } - imageView.setImageBitmap(overlay) - } else { - imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file, imageDimension)) + val displayable = file.takeUnless { it.isUpdateThumbnailNeeded }?.displayableThumbnailFromMemory() + if (displayable != null) { + imageView.setImageBitmap(displayable) + imageView.stopShimmer(shimmer) + bindGalleryRowListeners(imageView, file, galleryRowHolder) + return } + imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file, imageDimension)) + val job = ioScope.launch { try { galleryImageGenerationJob.run( @@ -164,6 +163,32 @@ class OCFileListDelegate( GalleryImageGenerationJob.storeJob(job, imageView) + bindGalleryRowListeners(imageView, file, galleryRowHolder) + } + + private fun OCFile.displayableThumbnailFromMemory(): Bitmap? { + val thumbnailKey = listOf(getBigThumbnailKey(), getSmallThumbnailKey()) + .firstOrNull { ThumbnailMemoryCache.get(it) != null } + ?: return null + val thumbnail = ThumbnailMemoryCache.get(thumbnailKey) + + return when { + thumbnail == null -> null + MimeTypeUtil.isVideo(this) -> withVideoOverlay(thumbnailKey, thumbnail) + else -> thumbnail + } + } + + private fun withVideoOverlay(thumbnailKey: String, thumbnail: Bitmap): Bitmap { + val overlayKey = videoOverlayKey(thumbnailKey) + + return ThumbnailMemoryCache.get(overlayKey) + ?: ThumbnailsCacheManager.addVideoOverlay(thumbnail, context).also { + ThumbnailMemoryCache.put(overlayKey, it) + } + } + + private fun bindGalleryRowListeners(imageView: ImageView, file: OCFile, galleryRowHolder: GalleryRowHolder) { imageView.setOnClickListener { if (context is AlbumsPickerActivity) { ocFileListFragmentInterface.onLongItemClicked( diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt index 44217195bf06..3febf194e283 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt @@ -354,6 +354,20 @@ open class ExtendedListFragment : override fun onDestroyView() { super.onDestroyView() binding = null + mRecyclerView?.setOnTouchListener(null) + mRecyclerView?.adapter = null + mRecyclerView?.layoutManager = null + mRecyclerView = null + mRefreshListLayout?.setOnRefreshListener(null) + mRefreshListLayout = null + mSortButton = null + mSwitchGridViewButton = null + mEmptyListContainer = null + mEmptyListMessage = null + mEmptyListHeadline = null + mEmptyListIcon = null + closeButton = null + mScaleGestureDetector = null } private inner class ScaleListener : SimpleOnScaleGestureListener() { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt index ae2d2306be15..ae44004e88c5 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt @@ -145,6 +145,8 @@ class GalleryFragment : LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(refreshSearchEventReceiver) adapter?.cleanup() + adapter?.setLayoutManager(null) + adapter = null super.onDestroyView() } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt index e4362c04e399..89407afdd83f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -231,6 +231,10 @@ class AlbumItemsFragment : override fun onDestroyView() { lastMediaItemPosition = 0 addMediaFab = null + binding.listRoot.adapter = null + binding.listRoot.layoutManager = null + adapter?.setLayoutManager(null) + adapter = null super.onDestroyView() } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 421323b07fd4..914fcc084ef9 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -385,16 +385,13 @@ class PreviewImageActivity : } private fun setDownloadedItem(downloadedFile: OCFile?) { - val position = savedPosition ?: return val adapter = previewMediaPagerAdapter ?: return - val file = downloadedFile ?: return + val position = savedPosition ?: return - if (adapter.getFileAt(position)?.fileId != file.fileId) { - return + if (downloadedFile != null && adapter.getFileAt(position)?.fileId == downloadedFile.fileId) { + adapter.updateFile(position, downloadedFile) + adapter.notifyItemChanged(position) } - - adapter.updateFile(position, file) - adapter.notifyItemChanged(position) } private fun selectPageOnDownload() { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index 577e14af9649..c383fa64c23e 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -155,6 +155,14 @@ class PreviewPlaybackFragment : super.onStop() } + override fun onDestroyView() { + playbackModel.clearVideoSurfaceView(binding.surfaceView) + binding.playerControlView.navigator = null + pictureInPictureCallback?.remove() + pictureInPictureCallback = null + super.onDestroyView() + } + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) render(playbackModel.state) @@ -186,7 +194,7 @@ class PreviewPlaybackFragment : viewLifecycleOwner.lifecycleScope.launch { val context = context ?: return@launch val size = context.resources.getDimension(R.dimen.player_album_cover_size).toInt() - thumbnailLoader.await(context, playbackFile, size, size)?.let(binding.thumbnail::setImageBitmap) + thumbnailLoader.await(playbackFile, size, size)?.let(binding.thumbnail::setImageBitmap) } } From b20cbbd5f589e5129abb02a5dc65d293eb9119c9 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 3 Sep 2026 10:19:57 +0200 Subject: [PATCH 58/69] disable pip in fragment Signed-off-by: alperozturk96 --- .../android/ui/adapter/GalleryAdapter.kt | 8 +++++ .../android/ui/fragment/GalleryFragment.kt | 2 -- .../ui/fragment/albums/AlbumItemsFragment.kt | 4 --- .../ui/preview/PreviewImageFragment.kt | 7 ++++- .../ui/preview/PreviewPlaybackFragment.kt | 31 +++++++++++++------ 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index 999ef89063ef..81ba72048c55 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -19,6 +19,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.VisibleForTesting +import androidx.recyclerview.widget.RecyclerView import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter import com.afollestad.sectionedrecyclerview.SectionedViewHolder import com.nextcloud.client.account.User @@ -318,6 +319,13 @@ class GalleryAdapter( } } + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + recyclerView.layoutManager = null + setLayoutManager(null) + cleanup() + } + override fun onBindFooterViewHolder(holder: SectionedViewHolder?, section: Int) = Unit override fun swapDirectory( diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt index ae44004e88c5..6d5404fd0dcc 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt @@ -144,8 +144,6 @@ class GalleryFragment : LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(refreshSearchEventReceiver) - adapter?.cleanup() - adapter?.setLayoutManager(null) adapter = null super.onDestroyView() diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt index 89407afdd83f..e4362c04e399 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -231,10 +231,6 @@ class AlbumItemsFragment : override fun onDestroyView() { lastMediaItemPosition = 0 addMediaFab = null - binding.listRoot.adapter = null - binding.listRoot.layoutManager = null - adapter?.setLayoutManager(null) - adapter = null super.onDestroyView() } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index 963f01b6134d..1cac1ef738b3 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -169,7 +169,12 @@ class PreviewImageFragment : private fun playLivePhoto(file: OCFile) { hideActionBar() - val mediaFragment = PreviewPlaybackFragment.newInstance(file, PlaybackCollection.FOLDER, autoplay = true) + val mediaFragment = PreviewPlaybackFragment.newInstance( + file, + PlaybackCollection.FOLDER, + autoplay = true, + pictureInPictureOnBack = false + ) requireActivity().supportFragmentManager.beginTransaction().run { replace(R.id.top, mediaFragment) addToBackStack(null) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index c383fa64c23e..f0b154c4d32b 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -52,17 +52,24 @@ class PreviewPlaybackFragment : private const val ARGUMENT_FILE = "ARGUMENT_FILE" private const val ARGUMENT_COLLECTION = "ARGUMENT_COLLECTION" private const val ARGUMENT_AUTOPLAY = "ARGUMENT_AUTOPLAY" + private const val ARGUMENT_PICTURE_IN_PICTURE_ON_BACK = "ARGUMENT_PICTURE_IN_PICTURE_ON_BACK" private const val SURFACE_ALPHA_VISIBLE = 1f private const val SURFACE_ALPHA_HIDDEN = 0f - fun newInstance(file: OCFile, collection: PlaybackCollection, autoplay: Boolean = false) = - PreviewPlaybackFragment().apply { - arguments = bundleOf( - ARGUMENT_FILE to file, - ARGUMENT_COLLECTION to collection, - ARGUMENT_AUTOPLAY to autoplay - ) - } + @Suppress("LongParameterList") + fun newInstance( + file: OCFile, + collection: PlaybackCollection, + autoplay: Boolean = false, + pictureInPictureOnBack: Boolean = true + ) = PreviewPlaybackFragment().apply { + arguments = bundleOf( + ARGUMENT_FILE to file, + ARGUMENT_COLLECTION to collection, + ARGUMENT_AUTOPLAY to autoplay, + ARGUMENT_PICTURE_IN_PICTURE_ON_BACK to pictureInPictureOnBack + ) + } } @Inject @@ -79,6 +86,7 @@ class PreviewPlaybackFragment : private lateinit var playbackFile: PlaybackFile private var playbackCollection = PlaybackCollection.FOLDER private var autoplay: Boolean = false + private var pictureInPictureOnBack: Boolean = true private var wasCurrentItem = false private var renderedVideoSize: VideoSize? = null @@ -93,6 +101,7 @@ class PreviewPlaybackFragment : playbackCollection = arguments.getSerializableArgument(ARGUMENT_COLLECTION, PlaybackCollection::class.java) ?: PlaybackCollection.FOLDER autoplay = arguments?.getBoolean(ARGUMENT_AUTOPLAY) == true + pictureInPictureOnBack = arguments?.getBoolean(ARGUMENT_PICTURE_IN_PICTURE_ON_BACK) != false } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -108,7 +117,11 @@ class PreviewPlaybackFragment : } private fun registerPictureInPictureOnBack() { - if (!MimeTypeUtil.isVideo(file) || !requireContext().isPictureInPictureAllowed()) { + if (!pictureInPictureOnBack || !MimeTypeUtil.isVideo(file)) { + return + } + + if (!requireContext().isPictureInPictureAllowed()) { return } From 25fe01ab4302de81bb3f156bbd33fcc919733cb2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 3 Sep 2026 11:45:30 +0200 Subject: [PATCH 59/69] wip Signed-off-by: alperozturk96 --- .../client/player/media3/PlaybackModel.kt | 10 +++++++++- .../android/ui/adapter/GalleryAdapter.kt | 1 - .../android/ui/adapter/OCFileListDelegate.kt | 4 ++-- .../ui/fragment/albums/AlbumItemsFragment.kt | 10 ++++------ .../android/ui/preview/PreviewImageFragment.kt | 18 +++++------------- 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt index df05320b990d..4abf61e2689e 100644 --- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -238,6 +238,7 @@ class PlaybackModel @Inject constructor( mediaSession?.player?.release() mediaSession?.release() mediaSession = null + notifyPlaybackUpdate() } fun clearVideoSurfaceView(surfaceView: SurfaceView) { @@ -317,12 +318,19 @@ class PlaybackModel @Inject constructor( } private fun notifyPlaybackUpdate() { - val currentState = state ?: return + val currentState = state ?: releasedState() for (i in listeners.indices) { listeners.getOrNull(i)?.onPlaybackUpdate(currentState) } } + private fun releasedState(): PlaybackState = PlaybackState( + currentFiles = emptyList(), + currentItemState = null, + repeatMode = playbackSettings.repeatMode, + shuffle = playbackSettings.isShuffle + ) + private fun notifyPlaybackError(error: Throwable) { for (i in listeners.indices) { listeners.getOrNull(i)?.onPlaybackError(error) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index 81ba72048c55..e6f831ca2d6a 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -321,7 +321,6 @@ class GalleryAdapter( override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { super.onDetachedFromRecyclerView(recyclerView) - recyclerView.layoutManager = null setLayoutManager(null) cleanup() } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt index edc14e4a4f37..961bbfacfeb9 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt @@ -48,7 +48,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.job import kotlinx.coroutines.launch @@ -451,7 +451,7 @@ class OCFileListDelegate( @Suppress("TooGenericExceptionCaught") fun cleanup() { - ioScope.cancel() + ioScope.coroutineContext.cancelChildren() try { GalleryImageGenerationJob.cancelAllActiveJobs() diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt index e4362c04e399..4c4d1cbd8e8f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -231,6 +231,7 @@ class AlbumItemsFragment : override fun onDestroyView() { lastMediaItemPosition = 0 addMediaFab = null + binding.listRoot.adapter = null super.onDestroyView() } @@ -318,7 +319,7 @@ class AlbumItemsFragment : setUpSelectionMode() } - binding.listRoot.adapter = adapter + if (binding.listRoot.adapter == null) binding.listRoot.adapter = adapter lastMediaItemPosition?.let { binding.listRoot.layoutManager?.scrollToPosition(it) } } @@ -563,21 +564,18 @@ class AlbumItemsFragment : MimeTypeUtil.isVideo(file) -> activity.startImagePreview(file, true, VirtualFolderType.ALBUM) - file.isDown && canBePreviewed(file) -> + file.isDown && activity.canMediaPreviewed(file) -> activity.startMediaPreview(file, showPreview = true, streamMedia = false) file.isDown -> containerActivity?.fileOperationsHelper?.openFile(file) - canBePreviewed(file) && !file.isEncrypted -> + activity.canMediaPreviewed(file) && !file.isEncrypted -> activity.startMediaPreview(file, showPreview = true, streamMedia = true) else -> Log_OC.d(TAG, "Couldn't handle item click") } } - fun canBePreviewed(file: OCFile?): Boolean = - file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) - override fun onLongItemClicked(file: OCFile): Boolean { if (selectionMode?.isActionModeActive == true) { toggleItemToCheckedList(file) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index 1cac1ef738b3..587fdf35d586 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -104,7 +104,7 @@ class PreviewImageFragment : FileFragment(), Injectable { private var showResizedImage: Boolean? = null - private var bitmap: Bitmap? = null + private var ownedBitmap: Bitmap? = null private var ignoreFirstSavedState = false private var loadBitmapTask: LoadBitmapTask? = null @@ -169,12 +169,7 @@ class PreviewImageFragment : private fun playLivePhoto(file: OCFile) { hideActionBar() - val mediaFragment = PreviewPlaybackFragment.newInstance( - file, - PlaybackCollection.FOLDER, - autoplay = true, - pictureInPictureOnBack = false - ) + val mediaFragment = PreviewPlaybackFragment.newInstance(file, PlaybackCollection.FOLDER, autoplay = true) requireActivity().supportFragmentManager.beginTransaction().run { replace(R.id.top, mediaFragment) addToBackStack(null) @@ -255,7 +250,6 @@ class PreviewImageFragment : binding.shimmer.visibility = View.VISIBLE binding.shimmerThumbnail.setImageBitmap(thumbnail) binding.image.visibility = View.GONE - bitmap = thumbnail } else { thumbnail = ThumbnailsCacheManager.mDefaultImg } @@ -280,8 +274,6 @@ class PreviewImageFragment : binding.emptyListView.visibility = View.GONE binding.emptyListProgress.visibility = View.GONE binding.image.setBackgroundColor(resources.getColor(R.color.background_color_inverse)) - - bitmap = resizedImage } else { // generate new resized image if (ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, binding.image) && @@ -485,7 +477,7 @@ class PreviewImageFragment : @SuppressFBWarnings("Dm") override fun onDestroy() { - bitmap?.recycle() + ownedBitmap?.recycle() super.onDestroy() } @@ -617,7 +609,7 @@ class PreviewImageFragment : showErrorMessage(mErrorMessageId) } - if (result?.bitmap != null && bitmap != result.bitmap) { + if (result?.bitmap != null && ownedBitmap != result.bitmap) { // unused bitmap, release it! (just in case) result.bitmap.recycle() } @@ -648,7 +640,7 @@ class PreviewImageFragment : imageView.setImageBitmap(bitmap) } - this@PreviewImageFragment.bitmap = bitmap // needs to be kept for recycling when not useful + this@PreviewImageFragment.ownedBitmap = bitmap } else { if (drawable != null && MIME_TYPE_SVG.equals(result.ocFile.mimeType, ignoreCase = true) From e802659c0ccfe34cfa8f0c20b4d6f1710f9a1b20 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 4 Sep 2026 15:00:08 +0200 Subject: [PATCH 60/69] Fix: now interface clears insets/notch Signed-off-by: daniele-verducci --- .../client/player/ui/control/PlayerControlView.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index 662ebe09b80e..3a4a7638c09c 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -15,6 +15,9 @@ import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.flowWithLifecycle @@ -86,6 +89,18 @@ class PlayerControlView @JvmOverloads constructor( viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) collectSeekBarChanges() } + + // Clear insets to avoid covering ui + ViewCompat.setOnApplyWindowInsetsListener(binding.progressPanel) { v, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updateLayoutParams { + topMargin = insets.top + leftMargin = insets.left + bottomMargin = insets.bottom + rightMargin = insets.right + } + WindowInsetsCompat.CONSUMED + } } override fun onDetachedFromWindow() { From ddbcdb084ce7d27ca41451b926c4876a9ab51ca0 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 4 Sep 2026 17:33:39 +0200 Subject: [PATCH 61/69] Fix: margins on player controls changing while swiping after rotation Signed-off-by: daniele-verducci --- app/src/main/res/layout/player_control_view.xml | 2 +- app/src/main/res/values-land/dims.xml | 2 -- app/src/main/res/values-large-land/dims.xml | 2 -- app/src/main/res/values-large/dims.xml | 2 -- app/src/main/res/values/dims.xml | 5 ++--- 5 files changed, 3 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/layout/player_control_view.xml b/app/src/main/res/layout/player_control_view.xml index bbaa8395a27c..ab2f2ba7461b 100644 --- a/app/src/main/res/layout/player_control_view.xml +++ b/app/src/main/res/layout/player_control_view.xml @@ -43,7 +43,7 @@ android:id="@+id/bottomBar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/player_bottom_margin_bottom" + android:paddingBottom="@dimen/standard_padding" android:gravity="center" android:orientation="horizontal"> diff --git a/app/src/main/res/values-land/dims.xml b/app/src/main/res/values-land/dims.xml index 5fef3610059e..c70fbb41b98a 100644 --- a/app/src/main/res/values-land/dims.xml +++ b/app/src/main/res/values-land/dims.xml @@ -9,6 +9,4 @@ 8dp 0dp 8dp - 0dp - 8dp \ No newline at end of file diff --git a/app/src/main/res/values-large-land/dims.xml b/app/src/main/res/values-large-land/dims.xml index 6851d8f72e9f..2ed7cf7567d2 100644 --- a/app/src/main/res/values-large-land/dims.xml +++ b/app/src/main/res/values-large-land/dims.xml @@ -9,6 +9,4 @@ 24dp 8dp 24dp - 16dp - 16dp \ No newline at end of file diff --git a/app/src/main/res/values-large/dims.xml b/app/src/main/res/values-large/dims.xml index 6ddef78fc83b..b7f1e53ab405 100644 --- a/app/src/main/res/values-large/dims.xml +++ b/app/src/main/res/values-large/dims.xml @@ -10,7 +10,5 @@ 24dp 24dp 48dp - 48dp 40dp - 72dp \ No newline at end of file diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 723a3ecf9e62..9d26887845be 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -176,8 +176,8 @@ 350dp 96dp 24dp - 8dp - 32dp + @dimen/standard_padding + @dimen/standard_padding 2dp 4dp 12dp @@ -187,5 +187,4 @@ 15sp 16dp 8dp - 48dp From 9eeff8323eab040e6f0eba1d3f5831ad288cbdc9 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 4 Sep 2026 18:45:42 +0200 Subject: [PATCH 62/69] WIP Signed-off-by: daniele-verducci --- .../player/ui/control/PlayerControlView.kt | 24 +++++++++---------- .../ui/preview/PreviewImageActivity.kt | 13 +++++++++- .../main/res/layout/player_control_view.xml | 1 + 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index 3a4a7638c09c..dd53aba7e43a 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -89,18 +89,6 @@ class PlayerControlView @JvmOverloads constructor( viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) collectSeekBarChanges() } - - // Clear insets to avoid covering ui - ViewCompat.setOnApplyWindowInsetsListener(binding.progressPanel) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updateLayoutParams { - topMargin = insets.top - leftMargin = insets.left - bottomMargin = insets.bottom - rightMargin = insets.right - } - WindowInsetsCompat.CONSUMED - } } override fun onDetachedFromWindow() { @@ -115,6 +103,18 @@ class PlayerControlView @JvmOverloads constructor( fun onStart() { playbackModel.state?.let(::render) playbackModel.addListener(this) + + // Clear insets to avoid covering ui + ViewCompat.setOnApplyWindowInsetsListener(binding.playerControlPanel) { v, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updateLayoutParams { + //topMargin = insets.top + leftMargin = insets.left + bottomMargin = insets.bottom + rightMargin = insets.right + } + WindowInsetsCompat.CONSUMED + } } fun onStop() { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 914fcc084ef9..b4885095ebd4 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -154,6 +154,8 @@ class PreviewImageActivity : observeWorkerState() applyDisplayCutOutTopPadding() + + handleBackPress() lifecycle.addObserver(sendShareDownloader) @@ -170,7 +172,16 @@ class PreviewImageActivity : window.decorView.setOnApplyWindowInsetsListener { view, insets -> updatePagerDisplayCutOutPadding(isInPictureInPictureMode, insets.displayCutout?.safeInsetTop ?: 0) view.onApplyWindowInsets(insets) - } + insets + } + /* + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + v.updatePadding( + top = insets.top + ) + WindowInsetsCompat.CONSUMED + }*/ } private fun updatePagerDisplayCutOutPadding(inPictureInPictureMode: Boolean, safeInsetTop: Int) { diff --git a/app/src/main/res/layout/player_control_view.xml b/app/src/main/res/layout/player_control_view.xml index ab2f2ba7461b..fc1b850c0e46 100644 --- a/app/src/main/res/layout/player_control_view.xml +++ b/app/src/main/res/layout/player_control_view.xml @@ -7,6 +7,7 @@ From 6efa55f09abdb9105e4906aff589016bcf6d29c1 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 4 Sep 2026 19:15:42 +0200 Subject: [PATCH 63/69] Fix: Better implementation for insets on player controls Signed-off-by: daniele-verducci --- .../player/ui/control/PlayerControlView.kt | 22 ++++++++++--------- .../ui/preview/PreviewImageActivity.kt | 11 +--------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index dd53aba7e43a..c4e43ffd040c 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -8,15 +8,15 @@ package com.nextcloud.client.player.ui.control import android.content.Context +import android.os.Build import android.util.AttributeSet import android.view.LayoutInflater +import android.view.WindowInsets import android.widget.LinearLayout import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -103,22 +103,24 @@ class PlayerControlView @JvmOverloads constructor( fun onStart() { playbackModel.state?.let(::render) playbackModel.addListener(this) + } + + fun onStop() { + playbackModel.removeListener(this) + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // Clear insets to avoid covering ui - ViewCompat.setOnApplyWindowInsetsListener(binding.playerControlPanel) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updateLayoutParams { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = rootWindowInsets.getInsets(WindowInsets.Type.systemBars()) + binding.playerControlPanel.updateLayoutParams { //topMargin = insets.top leftMargin = insets.left bottomMargin = insets.bottom rightMargin = insets.right } - WindowInsetsCompat.CONSUMED } - } - - fun onStop() { - playbackModel.removeListener(this) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) } override fun onPlaybackUpdate(state: PlaybackState) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index b4885095ebd4..39bec67bd903 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -172,16 +172,7 @@ class PreviewImageActivity : window.decorView.setOnApplyWindowInsetsListener { view, insets -> updatePagerDisplayCutOutPadding(isInPictureInPictureMode, insets.displayCutout?.safeInsetTop ?: 0) view.onApplyWindowInsets(insets) - insets - } - /* - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - v.updatePadding( - top = insets.top - ) - WindowInsetsCompat.CONSUMED - }*/ + } } private fun updatePagerDisplayCutOutPadding(inPictureInPictureMode: Boolean, safeInsetTop: Int) { From 7db6df665424524079ad004e529c568990a7a1c4 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 4 Sep 2026 19:27:32 +0200 Subject: [PATCH 64/69] Repeat and Random are mutually exclusive Signed-off-by: daniele-verducci --- .../nextcloud/client/player/ui/control/PlayerControlView.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index c4e43ffd040c..e6595adb1e5a 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -142,6 +142,9 @@ class PlayerControlView @JvmOverloads constructor( } binding.ivRepeat.setOnClickListener { + // Repeat and Random are mutually exclusive + playbackModel.setShuffle(false) + when (binding.ivRepeat.tag) { TAG_CLICK_COMMAND_REPEAT -> playbackModel.setRepeatMode(RepeatMode.SINGLE) TAG_CLICK_COMMAND_DO_NOT_REPEAT -> playbackModel.setRepeatMode(RepeatMode.ALL) @@ -149,6 +152,9 @@ class PlayerControlView @JvmOverloads constructor( } binding.ivRandom.setOnClickListener { + // Repeat and Random are mutually exclusive + playbackModel.setRepeatMode(RepeatMode.OFF) + playbackModel.setShuffle(binding.ivRandom.tag == TAG_CLICK_COMMAND_SHUFFLE) } From 36e8870ef19987fe53adcd7458b8a4c33cb5f317 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Sun, 6 Sep 2026 16:37:36 +0200 Subject: [PATCH 65/69] wip: working video change in pip mode in same folder Signed-off-by: daniele-verducci --- app/src/main/AndroidManifest.xml | 1 + .../ui/preview/PreviewImageActivity.kt | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f005e87a181c..a59e98831e5e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -387,6 +387,7 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:exported="false" android:supportsPictureInPicture="true" + android:launchMode="singleTask" android:theme="@style/Theme.ownCloud.Overlay" /> Date: Sun, 6 Sep 2026 16:48:58 +0200 Subject: [PATCH 66/69] Working video change in pip mode across different folders Signed-off-by: daniele-verducci --- .../com/owncloud/android/ui/preview/PreviewImageActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 7aac8397f490..e3df3cbdc52e 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -346,7 +346,8 @@ class PreviewImageActivity : // / Refresh the activity according to the Account and OCFile set setFile(file) // reset after getting it fresh from storageManager updateActionBarTitle(getFile()?.fileName) - if (previewMediaPagerAdapter == null) { + if (previewMediaPagerAdapter == null || previewMediaPagerAdapter?.getFilePosition(file) == -1) { + savedPosition = null initViewPager(optionalUser.get()) } else { previewMediaPagerAdapter?.getFilePosition(file)?.let { From 6f85844587cecc18897ba31470ed3c1cc4ac20d4 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Sun, 6 Sep 2026 17:26:17 +0200 Subject: [PATCH 67/69] Fix repeated items in viewpager Signed-off-by: daniele-verducci --- .../owncloud/android/datamodel/FileDataStorageManager.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index ad0ab96937ae..d1de246f1b18 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -524,12 +524,14 @@ public List getFolderImages(OCFile folder, boolean onlyOnDevice) { public List getFolderImagesAndVideos(OCFile folder, boolean onlyOnDevice) { List mediaList = new ArrayList<>(); + HashSet mediaFileNames = new HashSet<>(); if (folder != null) { List folderContent = getFolderContent(folder, onlyOnDevice); for (OCFile ocFile : folderContent) { - if (MimeTypeUtil.isImageOrVideo(ocFile)) { + if (MimeTypeUtil.isImageOrVideo(ocFile) && !mediaFileNames.contains(ocFile.getFileName())) { + mediaFileNames.add(ocFile.getFileName()); mediaList.add(ocFile); } } From 16663f9b7b1eeaad8c32be9c24a23a2023b20880 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Sun, 6 Sep 2026 17:39:46 +0200 Subject: [PATCH 68/69] Fix lint Signed-off-by: daniele-verducci --- .../player/ui/control/PlayerControlView.kt | 1 - .../ui/preview/PreviewImageActivity.kt | 52 ++++++++++--------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt index e6595adb1e5a..b80b01e3299b 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -114,7 +114,6 @@ class PlayerControlView @JvmOverloads constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val insets = rootWindowInsets.getInsets(WindowInsets.Type.systemBars()) binding.playerControlPanel.updateLayoutParams { - //topMargin = insets.top leftMargin = insets.left bottomMargin = insets.bottom rightMargin = insets.right diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index e3df3cbdc52e..465bbf594c42 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -153,7 +153,6 @@ class PreviewImageActivity : observeWorkerState() applyDisplayCutOutTopPadding() - handleBackPress() lifecycle.addObserver(sendShareDownloader) @@ -331,33 +330,36 @@ class PreviewImageActivity : val chosenFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) val optionalUser = user - if (optionalUser.isPresent) { - var file: OCFile? = chosenFile ?: file ?: throw IllegalStateException("Instanced with a NULL OCFile") - //updateActionBarTitle(file?.fileName) - // / Validate handled file (first media item to preview) - require(MimeTypeUtil.isImageOrVideo(file)) { "Non-image/video file passed as argument" } - - // Update file according to DB file, if it is possible - if (file!!.fileId > FileDataStorageManager.ROOT_PARENT_ID) { - file = storageManager.getFileById(file.fileId) - } + if (!optionalUser.isPresent) { + finish() + return + } - if (file != null) { - // / Refresh the activity according to the Account and OCFile set - setFile(file) // reset after getting it fresh from storageManager - updateActionBarTitle(getFile()?.fileName) - if (previewMediaPagerAdapter == null || previewMediaPagerAdapter?.getFilePosition(file) == -1) { - savedPosition = null - initViewPager(optionalUser.get()) - } else { - previewMediaPagerAdapter?.getFilePosition(file)?.let { - viewPager?.currentItem = it - } - } + var file: OCFile? = chosenFile ?: file ?: throw IllegalStateException("Instanced with a NULL OCFile") + // updateActionBarTitle(file?.fileName) + // / Validate handled file (first media item to preview) + require(MimeTypeUtil.isImageOrVideo(file)) { "Non-image/video file passed as argument" } + + // Update file according to DB file, if it is possible + if (file!!.fileId > FileDataStorageManager.ROOT_PARENT_ID) { + file = storageManager.getFileById(file.fileId) + } + + if (file != null) { + // / Refresh the activity according to the Account and OCFile set + setFile(file) // reset after getting it fresh from storageManager + updateActionBarTitle(getFile()?.fileName) + if (previewMediaPagerAdapter == null || previewMediaPagerAdapter?.getFilePosition(file) == -1) { + savedPosition = null + initViewPager(optionalUser.get()) } else { - // handled file not in the current Account - finish() + previewMediaPagerAdapter?.getFilePosition(file)?.let { + viewPager?.currentItem = it + } } + } else { + // handled file not in the current Account + finish() } } From 52d29c241ac4ee4ac346e05dc019336811f1dd1b Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Sun, 6 Sep 2026 18:13:52 +0200 Subject: [PATCH 69/69] Fix detectWrongSettings: Update android-library Signed-off-by: daniele-verducci --- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b67a46e131d2..56b5905afd71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "0.33.2" androidGifDrawableVersion = "1.2.32" androidImageCropperVersion = "4.7.0" -androidLibraryVersion ="94372d7cbb8efe1c779ab8c2244a61510efafb28" +androidLibraryVersion ="d22a01f4b8327b6593d6af75f6be3aa7fbc2c854" androidOpensslVersion = "3.5.6" androidPluginVersion = "9.3.2" androidsvgVersion = "1.4" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index dc246117dcf0..5a4972919d98 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -22188,6 +22188,14 @@ + + + + + + + +