From 6364f8ee840f74b65bcbb959c7eda1289f38979e Mon Sep 17 00:00:00 2001 From: Denis Botvin Date: Mon, 17 Nov 2025 15:55:37 +0200 Subject: [PATCH 01/26] 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 --- 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 + app/src/main/res/values/dims.xml | 24 ++ .../MediaControllerExtensionsTest.kt | 107 ++++++ .../error/DefaultPlaybackErrorStrategyTest.kt | 67 ++++ .../model/file/PlaybackFilesComparatorTest.kt | 145 ++++++++ .../client/player/util/ListExtensionsTest.kt | 28 ++ 132 files changed, 7253 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 30f0dba91170..3d190f513cbc 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 164df5698362..877e6e50f972 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 1db87a82ea63..633eac0f078d 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2000,6 +2000,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 @@ -2844,6 +2873,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 e164a0da1688..b820a2f6d9b1 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 0945960e09fc..d993e6f09d44 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 @@ -1551,6 +1551,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/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index ffc32ec8b7a8..39c0115ef799 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -155,4 +155,28 @@ 18dp 18dp 24dp + + + 56dp + 21sp + 4dp + 12dp + 16dp + 16dp + 16sp + 8dp + 12sp + 32dp + 350dp + 96dp + 20dp + 8dp + 32dp + 2dp + 4dp + 10dp + 15sp + 16dp + 8dp + 48dp 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 2bda8ac595c7b2c7b323a59ac81207a21b209bcb Mon Sep 17 00:00:00 2001 From: Alex Kucherenko Date: Fri, 12 Dec 2025 13:18:28 +0200 Subject: [PATCH 02/26] 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 a2e7b06c43c69b0dea6c2c4266903fccb153f383 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:20:12 +0100 Subject: [PATCH 03/26] 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 85c4a12c938a1d3e23400eff82400fdaa55435a1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:20:13 +0100 Subject: [PATCH 04/26] 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 877e6e50f972..f9e476e9c82b 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 633eac0f078d..5dc81f1da345 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2874,7 +2874,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 dd932f9f64ee..167f3e32b2d9 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; @@ -149,6 +150,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. */ @@ -728,6 +732,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); @@ -737,6 +742,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 d993e6f09d44..0945960e09fc 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 @@ -1551,10 +1551,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 c1bee6a8f86864520e8fe1ea98fa0a0d1bd4c92a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:27:49 +0100 Subject: [PATCH 05/26] 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 38daddd160a28bfa46e52a6e44cbf7c7dd1bea90 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:44:58 +0100 Subject: [PATCH 06/26] 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 3d190f513cbc..d01cf0ce6cb9 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 91237ec84f63..48aea9aa1e80 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 @@ -183,10 +183,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 86d4b8282c81e8a11f32d8aa3b5ff2d24e98c080 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:55:12 +0100 Subject: [PATCH 07/26] 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 f79b0808670f658d998cd80955b6807490444bf8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 09:00:26 +0100 Subject: [PATCH 08/26] 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 167f3e32b2d9..c27c32f8733b 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 @@ -151,7 +151,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 bae855fc88d8551371a89504b2b75a74ff238886 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 12:07:35 +0100 Subject: [PATCH 09/26] 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 a88b4e46ccf564d97259c0e6ab4112645c17a9ce Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 12:20:50 +0100 Subject: [PATCH 10/26] 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 f5416ac3f6ed0353f22fd2cdb8c91f168bb05e6a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 14:41:47 +0200 Subject: [PATCH 11/26] 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 cdd4f02ac26e431ee9c4a8972bb8b23d51d354a0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:33:20 +0200 Subject: [PATCH 12/26] 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 8ab19fd25380aa038b1a5cac3dcae384b06c9611 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:42:03 +0200 Subject: [PATCH 13/26] 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 cfbe58449d3eef614fbc8c32e5dcb56ec4173c4e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:42:03 +0200 Subject: [PATCH 14/26] 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 86d326d5c6ae..f0f7b50f4133 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 @@ -409,7 +409,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 0eed351e4acd..70fe3a5c6f2c 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 @@ -470,7 +470,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 a81d09ddda9c163f1f5f7f0fc2657317993b1b7b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:53:35 +0200 Subject: [PATCH 15/26] 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 51d1bc2b03a9efefe452f9e558b1dd706b79ca3a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 16:03:06 +0200 Subject: [PATCH 16/26] 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 dfa09716b7074fe845d3dab029cd0fbbb30faaa6 Mon Sep 17 00:00:00 2001 From: Alex Kucherenko Date: Fri, 22 May 2026 00:44:50 +0300 Subject: [PATCH 17/26] Update license identifier --- 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 0dc6fce62eaca0f37972195929fd50e6cf3884aa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 14 Aug 2026 09:18:42 +0200 Subject: [PATCH 18/26] 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 | 32 ++++---- .../res/layout/activity_preview_media.xml | 82 ------------------- app/src/main/res/values/strings.xml | 13 +++ app/src/main/res/values/styles.xml | 39 +++++++++ gradle/libs.versions.toml | 4 + 10 files changed, 94 insertions(+), 138 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 2569f38154ba..d22d2a8b902d 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; @@ -92,7 +93,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; @@ -252,7 +252,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 51a47f0c0c48..614b4254754c 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.WorkerState.OfflineOperationsCompleted @@ -143,7 +143,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 @@ -225,7 +224,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 @@ -233,6 +231,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 @@ -302,10 +303,7 @@ class FileDisplayActivity : showSortListGroup(savedInstanceState.getBoolean(KEY_IS_SORT_GROUP_VISIBLE)) } - mPlayerConnection = PlayerServiceConnection(this) - checkStoragePath() - observeWorkerState() startMetadataSyncForRoot() handleBackPress() @@ -865,6 +863,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 @@ -877,7 +878,7 @@ class FileDisplayActivity : true } - PreviewMediaActivity.canBePreviewed(file) -> { + canMediaPreviewed(file) -> { startMediaPreview(file, 0, true, true, true, true) true } @@ -2053,7 +2054,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 { @@ -2204,7 +2205,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 @@ -2309,13 +2310,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. * @@ -2713,10 +2707,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) @@ -2733,18 +2726,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 0945960e09fc..f4f27aea34d1 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 @@ -115,7 +115,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.EncryptionUtils; import com.owncloud.android.utils.EncryptionUtilsV2; @@ -1253,7 +1252,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 48aea9aa1e80..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 @@ -29,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 @@ -60,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,17 +95,17 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { mStorageManager = storageManager if (type == VirtualFolderType.GALLERY) { - imageFiles = mStorageManager.allGalleryItems - imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) + mediaFiles = mStorageManager.allGalleryItems + mediaFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) } else { - imageFiles = mStorageManager.getVirtualFolderContent(type, true) + 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() @@ -115,7 +115,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } fun delete(position: Int) { - if (position < 0 || position >= imageFiles.size) { + if (position < 0 || position >= mediaFiles.size) { return } @@ -125,7 +125,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { mObsoletePositions.add(position) - imageFiles.removeAt(position) + mediaFiles.removeAt(position) mDownloadErrors.remove(position) mCachedFragments.remove(position) @@ -134,7 +134,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { @Suppress("TooGenericExceptionCaught") fun getFileAt(position: Int): OCFile? = try { - imageFiles[position] + mediaFiles[position] } catch (_: IndexOutOfBoundsException) { null } @@ -180,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/strings.xml b/app/src/main/res/values/strings.xml index 3b6cb7d99a11..722c2ae5da2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1549,4 +1549,17 @@ You do not have permission to change this label Governance Image details + + + Export started (%d file) + Exports started (%d files) + + Source not found + Close + Modified: %s + Shuffle button + Previous button + Play/Pause button + Next button + Random button diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c3f303d1839a..49d43f7249d5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -515,4 +515,43 @@ + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a2b76c85ac1..1cc5b3ac3eb1 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 3e2c20c5b5496423e4a45851a2ee6a3ae63f42d7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 14 Aug 2026 10:35:40 +0200 Subject: [PATCH 19/26] 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/strings.xml | 6 +----- app/src/main/res/values/styles.xml | 5 ----- 6 files changed, 12 insertions(+), 12 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" /> 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 @@ -1269,8 +1267,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. @@ -1557,7 +1553,7 @@ Source not found Close Modified: %s - Shuffle button + Repeat button Previous button Play/Pause button Next button diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 49d43f7249d5..448112367752 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -231,11 +231,6 @@ false - - From 40a1b7d3de5f439c0203af3fa1e331b5b6164f71 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 14 Aug 2026 11:04:52 +0200 Subject: [PATCH 20/26] 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 b820a2f6d9b1..26a170aaab42 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 ebede089c4a60e78df5d34ec8c1d2188fd5bdd09 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 14:57:21 +0200 Subject: [PATCH 21/26] wip Signed-off-by: alperozturk96 --- .../ui/adapter/ListGridItemViewHolder.kt | 2 + .../ui/adapter/OCFileListItemViewHolder.kt | 4 +- .../ui/preview/PreviewMediaActivity.kt | 856 ------------------ 3 files changed, 5 insertions(+), 857 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 55b653f5e46d..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ /dev/null @@ -1,856 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2023 Alper Ozturk - * SPDX-FileCopyrightText: 2023 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.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.RemoveFileOperation -import com.owncloud.android.operations.SynchronizeFileOperation -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) { - val file = file - - if (storageManager != null && file != null) { - val updatedFile = storageManager.getFileById(file.fileId) - setFile(updatedFile) - val fileNew = getFile() - fileNew?.let { showFileActions(it) } - } - } - - return super.onOptionsItemSelected(item) - } - - 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) - } - } - } - - 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) - } - } - - private fun onSynchronizeFileOperationFinish(result: RemoteOperationResult<*>?) { - result?.let { - invalidateOptionsMenu() - } - } - - 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 4573adfa3bb0a84c84f72bb79cda54b1022a9bdf Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 14:59:01 +0200 Subject: [PATCH 22/26] 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 0581b99ffaf44e4074084d6e1f394a0d54e92e71 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 15:03:07 +0200 Subject: [PATCH 23/26] 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 aafe983336df004a49bce3ee8cf9bf979beaea61 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 16:07:36 +0200 Subject: [PATCH 24/26] 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 614b4254754c..6690d207d5b9 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 @@ -2051,9 +2051,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 f4f27aea34d1..bd1ab0b1039b 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 @@ -120,7 +120,6 @@ import com.owncloud.android.utils.EncryptionUtilsV2; 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; @@ -155,7 +154,6 @@ import static com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG; 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; @@ -1198,7 +1196,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); @@ -1207,20 +1205,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; @@ -1249,10 +1233,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 556a7351355e1065a8b3fafbcae51101001b477c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 16:15:58 +0200 Subject: [PATCH 25/26] simplify approach Signed-off-by: alperozturk96 --- .../nextcloud/client/di/ViewModelModule.kt | 6 + .../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 +- 59 files changed, 587 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/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index eb3e98a6c570..53c349fd0b9f 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.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel @@ -57,6 +58,11 @@ abstract class ViewModelModule { @ViewModelKey(TrashbinFileActionsViewModel::class) abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): 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/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 c27c32f8733b..e8c8afce4a73 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 f76632fb69aa05860b5048caeb8467380338d6fc Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 17:11:17 +0200 Subject: [PATCH 26/26] 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 | 613 ------------------ .../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 - app/src/main/res/values/strings.xml | 15 - .../client/media/AudioFocusManagerTest.kt | 63 -- .../nextcloud/client/media/AudioFocusTest.kt | 40 -- 33 files changed, 303 insertions(+), 2307 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 60f966320d2d..99cb9f448a6d 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; @@ -121,7 +120,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; @@ -285,9 +284,6 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract BackupListFragment chooseContactListFragment(); - @ContributesAndroidInjector - abstract PreviewMediaFragment previewMediaFragment(); - @ContributesAndroidInjector abstract PreviewTextFragment previewTextFragment(); @@ -297,6 +293,9 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract SetOnlineStatusBottomSheet setOnlineStatusBottomSheet(); + @ContributesAndroidInjector + abstract PreviewPlaybackFragment previewPlaybackFragment(); + @ContributesAndroidInjector abstract PreviewTextFileFragment previewTextFileFragment(); @@ -490,9 +489,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 6690d207d5b9..89faffefe038 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 @@ -143,8 +143,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 @@ -879,7 +877,7 @@ class FileDisplayActivity : } canMediaPreviewed(file) -> { - startMediaPreview(file, 0, true, true, true, true) + startMediaPreview(file, true, true) true } @@ -2053,7 +2051,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) } @@ -2406,22 +2404,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)) { @@ -2686,37 +2668,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) @@ -2918,17 +2882,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( @@ -3313,6 +3268,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 bd1ab0b1039b..9a80cdf078b9 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 @@ -1235,7 +1235,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 e180a0cdef07..71895ff2ad5c 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 @@ -65,7 +65,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 @@ -159,16 +158,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 70fe3a5c6f2c..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ /dev/null @@ -1,613 +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.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.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 - - val updatedFile = containerActivity.storageManager.getFileById(file.fileId) - file = updatedFile - file?.let { newFile -> - showFileActions(newFile) - } - - true - } - - else -> false - } - } - }, - viewLifecycleOwner, - Lifecycle.State.RESUMED - ) - } - - 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) - } - } - } - - /** - * 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2aab685e5e89..d63bc79964d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -401,15 +401,6 @@ Passcode deleted Passcode stored - 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. @@ -479,11 +470,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 @@ -1054,7 +1040,6 @@ Delete permanently Stream with… Internal streaming not possible - Please download media instead or use external app. Folder already exists Notification icon Create 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) - } - } -}