From d8448f04de82d7df538fe3f5f6cdf431905be0d4 Mon Sep 17 00:00:00 2001 From: Denis Botvin Date: Mon, 17 Nov 2025 15:55:37 +0200 Subject: [PATCH 01/20] 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 --- 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 +++ .../ui/activity/FileDisplayActivity.kt | 19 +- .../ui/adapter/ListGridItemViewHolder.kt | 2 + .../android/ui/adapter/OCFileListAdapter.java | 4 + .../adapter/OCFileListGridItemViewHolder.kt | 3 + .../ui/adapter/OCFileListItemViewHolder.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 ++ 135 files changed, 7265 insertions(+), 13 deletions(-) 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 17741a007775..38da4959ee11 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -498,6 +498,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 0abb03bf8474..049a56ea99be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -395,9 +395,14 @@ android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="false" android:theme="@style/Theme.ownCloud.Media" /> + @@ -406,6 +411,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 f8d5f3855c30..00c441a2a970 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 @@ -68,6 +68,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 a82ad391bb5d..0491b960686b 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -1975,6 +1975,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 @@ -2803,6 +2832,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 47ecdab72fcd..9ecfdad1c097 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/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index db6f451d3ef8..8e8baafe21f0 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 @@ -73,6 +73,7 @@ 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 @@ -252,6 +253,9 @@ class FileDisplayActivity : @Inject lateinit var syncedFolderProvider: SyncedFolderProvider + @Inject + lateinit var playerLauncher: PlayerLauncher + /** * Indicates whether the downloaded file should be previewed immediately. Since `FileDownloadWorker` can be * triggered from multiple sources, this helps determine if an automatic preview is needed after download. @@ -2670,18 +2674,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, startPlaybackPosition: Long, autoplay: Boolean, user: User?) { + val searchType = listOfFilesFragment?.currentSearchType + playerLauncher.launch(this, file, searchType) } fun configureToolbarForPreview(file: OCFile?) { 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 bbbfc9ade682..f9b66ffc3f85 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 @@ -8,8 +8,10 @@ package com.owncloud.android.ui.adapter 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 } 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 a2c7616dda3c..59fb8fdaa537 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 @@ -553,6 +553,10 @@ private void setFilenameAndExtension(ListGridItemViewHolder holder, OCFile file) } else { handleListMode(holder, pair, isFolder); } + + 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 484bf8f62622..da12731cb411 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) : @@ -32,6 +33,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/adapter/OCFileListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt index 3336b2b62663..1c845d08a078 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 @@ -47,6 +48,8 @@ class OCFileListItemViewHolder(private var binding: ListItemBinding) : get() = binding.Filename override val extension: TextView get() = binding.extension + override val playerProgressIndicator: PlayerProgressIndicator + get() = binding.playerProgressIndicator override val thumbnail: ImageView get() = binding.thumbnailLayout.thumbnail override val tagsGroup: ChipGroup 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 d31dd21f5fba..f89a79cdc8ab 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 @@ -1503,6 +1503,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 ddbbc8c627c3..79ed733ba2f8 100644 --- a/app/src/main/res/layout/grid_item.xml +++ b/app/src/main/res/layout/grid_item.xml @@ -231,5 +231,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 8717f2ffc7aa..96d2b5041b25 100644 --- a/app/src/main/res/layout/list_item.xml +++ b/app/src/main/res/layout/list_item.xml @@ -225,6 +225,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 2db10bcc81a9..acccf978ed60 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 5fa7769731b77e4e5546fed20127a361e08f4675 Mon Sep 17 00:00:00 2001 From: Alex Kucherenko Date: Fri, 12 Dec 2025 13:18:28 +0200 Subject: [PATCH 02/20] 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 3516bf4564ce..e0a2c0c59148 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 @@ -131,6 +134,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 { @@ -139,6 +143,10 @@ class AccountRemovalDialog : } } + private fun stopMediaPlayerAndHidePip() { + playbackModel.release() + } + /** * Start avatar generation. */ From 8c30cf336c4eebda81c8f27b454cba96cac4579b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:20:12 +0100 Subject: [PATCH 03/20] 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 ce9a4686bcabc0530b93da0ba5911da0101ad96d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:20:13 +0100 Subject: [PATCH 04/20] 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 00c441a2a970..1851c31335aa 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 @@ -68,13 +68,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 @@ -159,6 +152,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 0491b960686b..e4111a94ca90 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2833,7 +2833,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 27340b873e68..167b06af76d4 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 e0a2c0c59148..aeacbe39ede0 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 @@ -20,6 +20,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 f89a79cdc8ab..d31dd21f5fba 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 @@ -1503,10 +1503,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 aded2c0b3577..58002cd3c3a3 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 2f7ae020b271636aab43262ad0e1c47010e6ab18 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:27:49 +0100 Subject: [PATCH 05/20] 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 9c55c37fa123108b74f6ee4207b5626020d76059 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:44:58 +0100 Subject: [PATCH 06/20] 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 ------ .../android/ui/activity/FileActivity.java | 4 +- .../ui/fragment/OCFileListFragment.java | 3 +- .../ui/preview/PreviewImagePagerAdapter.kt | 7 +- .../res/layout/activity_preview_media.xml | 82 --- .../client/media/PlayerStateMachineTest.kt | 670 ------------------ 12 files changed, 7 insertions(+), 1635 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/main/res/layout/activity_preview_media.xml 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 049a56ea99be..4c012d5616da 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -390,11 +390,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/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java index ec834e983c1d..afc223c76f67 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 @@ -38,6 +38,7 @@ import com.nextcloud.client.jobs.download.FileDownloadWorker; import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.player.ui.PlayerActivity; import com.nextcloud.receiver.NetworkChangeListener; import com.nextcloud.receiver.NetworkChangeReceiver; import com.nextcloud.utils.EditorUtils; @@ -95,7 +96,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; @@ -267,7 +267,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/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index d31dd21f5fba..f58c4128d95c 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 @@ -112,7 +112,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; @@ -1212,7 +1211,7 @@ private void handlePendingDownloadFile(OCFile file) { User account = accountManager.getUser(); OCCapability capability = mContainerActivity.getStorageManager().getCapability(account.getAccountName()); - if (PreviewMediaActivity.Companion.canBePreviewed(file) && !file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda) { + if (!file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda && fda.canBePreviewed(file)) { setFabVisible(false); fda.startMediaPreview(file, 0, true, true, true, true); } else if (editorUtils.isEditorAvailable(accountManager.getUser(), file.getMimeType()) && !file.isEncrypted()) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt index 9f75a6972e05..31621ee5d406 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt @@ -184,10 +184,11 @@ class PreviewImagePagerAdapter : 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/main/res/layout/activity_preview_media.xml b/app/src/main/res/layout/activity_preview_media.xml deleted file mode 100644 index 1ff0dd7c04ba..000000000000 --- a/app/src/main/res/layout/activity_preview_media.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - 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 efd756632a2dea718d9d75a36bc2548c74f47993 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 16 Feb 2026 15:55:12 +0100 Subject: [PATCH 07/20] 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 b6ee283eebdcff9fc77510c0d695f24d420d503e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 09:00:26 +0100 Subject: [PATCH 08/20] 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 167b06af76d4..1b8b560b9085 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 4a02a4b6dd7d3fc981146d659ab7dbf4c0d9654a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 12:07:35 +0100 Subject: [PATCH 09/20] 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 5e0e5b8664a18911b4800f13f9d3ace651ba57c6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 17 Feb 2026 12:20:50 +0100 Subject: [PATCH 10/20] 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 16339ce7856202a301f3f97ebbb2f53e52b8e461 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 18 Mar 2026 08:30:07 +0100 Subject: [PATCH 11/20] add strings Signed-off-by: alperozturk96 --- app/src/main/res/values/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49d8782efdfb..7a775f1b83a7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1522,4 +1522,17 @@ Upload conflicts detected. Open uploads to resolve. Resolve conflicts Extension cannot be changed + + + 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 From 7a15f4ef47a073120e398fe808c31eda6ce4ddf9 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 26 Mar 2026 16:20:55 +0100 Subject: [PATCH 12/20] remove unused preview media activity Signed-off-by: alperozturk96 --- .../ui/preview/PreviewMediaActivity.kt | 867 ------------------ 1 file changed, 867 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/preview/PreviewMediaActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt deleted file mode 100644 index abc808e6f1eb..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ /dev/null @@ -1,867 +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.app.Activity -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.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.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.ui.fragment.OCFileListFragment -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 lateinit var binding: ActivityPreviewMediaBinding - 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 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() - 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 - } - - binding.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) - } - } - - 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)) { - initializeVideoPlayer() - } - } - - 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, - binding.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 = binding.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() - binding.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.setFullscreenButtonClickListener { startFullScreenVideo() } - } - } - - private fun startFullScreenVideo() { - val client = nextcloudClient ?: return - val player = videoPlayer ?: return - val dialog = PreviewVideoFullscreenDialog( - this, - client, - player, - binding.exoplayerView - ) - .apply { - setOnDismissListener { - setupVideoView() - } - } - - 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.syncFile(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, null) - } - } - } - - 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?) { - requestForDownload(file, OCFileListFragment.DOWNLOAD_SEND, packageName, activityName) - } - - private fun requestForDownload( - file: OCFile?, - downloadBehavior: String? = null, - packageName: String? = null, - activityName: String? = null - ) { - val fileDownloadHelper = FileDownloadHelper.instance() - - if (fileDownloadHelper.isDownloading(user, file)) { - return - } - - user?.let { user -> - file?.let { file -> - fileDownloadHelper.downloadFile( - user, - file, - downloadBehavior ?: "", - DownloadType.DOWNLOAD, - packageName ?: "", - activityName ?: "" - ) - } - } - } - - 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) } - super.onDestroy() - - Log_OC.v(TAG, "onDestroy") - } - - override fun onStop() { - Log_OC.v(TAG, "onStop") - - 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?) { - // TODO Auto-generated method stub - } - - override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) { - // TODO Auto-generated method stub - } - - 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) ?: 0 - autoplay = data?.getBooleanExtra(EXTRA_AUTOPLAY, false) ?: false - } - } - - /** - * Opens the previewed file with an external application. - */ - 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" - - /** - * Helper method to test if an [OCFile] can be passed to a [PreviewMediaActivity] to be previewed. - * - * @param file File to test if can be previewed. - * @return 'True' if the file can be handled by the activity. - */ - fun canBePreviewed(file: OCFile?): Boolean = - file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) - } -} From ef95359622348486ec98771a0519266cc78affc5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 14:41:47 +0200 Subject: [PATCH 13/20] 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 4091072bbf4b513846211616f64403de7b3bd72b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:33:20 +0200 Subject: [PATCH 14/20] 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 4f562b674f1a10adc75e1e395905c5cddb51daf8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:42:03 +0200 Subject: [PATCH 15/20] 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 c51442cec45cb34338571b5ccfc3a61e3c9db037 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:42:03 +0200 Subject: [PATCH 16/20] 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 1062dfc933f6..f0dfb647b07f 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 @@ -416,7 +416,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 5a19c90b77c3..1ca501abd06e 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 @@ -481,7 +481,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 4a1d81140f65fbd3b632b3a8af336a0ef6b7e385 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 15:53:35 +0200 Subject: [PATCH 17/20] 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 953408361d4bd9bbbd6f160c46ac332045f6d98c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 23 Apr 2026 16:03:06 +0200 Subject: [PATCH 18/20] 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 24fd2dc1b21526a8dce4a9b12ff7975b74de8638 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 21 May 2026 15:45:45 +0300 Subject: [PATCH 19/20] wip Signed-off-by: alperozturk96 --- .../ui/activity/FileDisplayActivity.kt | 22 +++-------- app/src/main/res/values/styles.xml | 39 +++++++++++++++++++ gradle/libs.versions.toml | 5 +++ 3 files changed, 50 insertions(+), 16 deletions(-) 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 8e8baafe21f0..697ff49aa76f 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,7 +71,6 @@ 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 @@ -143,7 +142,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 @@ -223,7 +221,6 @@ class FileDisplayActivity : private var searchOpen = false private var searchView: SearchView? = null - private var mPlayerConnection: PlayerServiceConnection? = null private var lastDisplayedAccountName: String? = null @Inject @@ -295,10 +292,7 @@ class FileDisplayActivity : showSortListGroup(savedInstanceState.getBoolean(KEY_IS_SORT_GROUP_VISIBLE)) } - mPlayerConnection = PlayerServiceConnection(this) - checkStoragePath() - observeWorkerState() startMetadataSyncForRoot() handleBackPress() @@ -866,6 +860,9 @@ class FileDisplayActivity : } } + fun canBePreviewed(file: OCFile?): Boolean = + file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) + private fun tryStartWaitingPreview(success: Boolean): Boolean { if (!success) return false @@ -873,7 +870,7 @@ class FileDisplayActivity : val file = mWaitingToPreview ?: return false return when { - PreviewMediaActivity.canBePreviewed(file) -> { + canBePreviewed(file) -> { startMediaPreview(file, 0, true, true, true, true) true } @@ -2039,7 +2036,7 @@ class FileDisplayActivity : } else if (PreviewTextFileFragment.canBePreviewed(file)) { setFabVisible?.onComplete(false) startTextPreview(file, false) - } else if (PreviewMediaActivity.Companion.canBePreviewed(file)) { + } else if (canBePreviewed(file)) { setFabVisible?.onComplete(false) startMediaPreview(file, 0, true, true, false, true) } else { @@ -2181,7 +2178,7 @@ class FileDisplayActivity : if (result.isSuccess) { 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 @@ -2299,13 +2296,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. * diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 10ff22564f99..7ce83ad5e140 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -497,4 +497,43 @@ @android:color/black @color/primary + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21cc251dfeda..4ac7f675d36d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,7 @@ stateless4jVersion = "2.6.0" webkitVersion = "1.16.0" workRuntime = "2.11.2" foundationVersion = "1.11.2" +kotlinxCoroutinesVersion = "1.11.0" [libraries] # Crypto @@ -243,6 +244,10 @@ 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" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", 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 48c795227469dc1b968eae1278f614ce25830852 Mon Sep 17 00:00:00 2001 From: Alex Kucherenko Date: Fri, 22 May 2026 00:44:50 +0300 Subject: [PATCH 20/20] 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