diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c5764b7af6b..f10c5a16c3ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -499,6 +499,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 4154b1c4d27a..3bd6e4c86648 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -382,9 +382,14 @@ android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="false" android:theme="@style/Theme.ownCloud.Media" /> + @@ -393,6 +398,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 3356d69d0be5..3438250d5611 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 @@ -53,6 +53,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 517f40263400..c62fd004a2e4 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -1977,6 +1977,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 @@ -2810,6 +2839,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 19e5fc0b2f4a..03f97cf2f1fe 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; @@ -80,6 +82,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; @@ -95,15 +100,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; } @@ -217,6 +228,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; } @@ -538,15 +554,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; } @@ -577,6 +599,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/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 2b0d1c0d2c2e..0ac1f0f04c72 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 @@ -49,6 +49,7 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.navigation.NavigationView; import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.ionos.player.model.PlaybackModel; import com.nextcloud.client.account.User; import com.nextcloud.client.di.Injectable; import com.nextcloud.client.files.DeepLinkConstants; @@ -208,6 +209,9 @@ public abstract class DrawerActivity extends ToolbarActivity @Inject ClientFactory clientFactory; + @Inject + PlaybackModel playbackModel; + @Override public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableBundle persistentState) { super.onCreate(savedInstanceState, persistentState); @@ -676,6 +680,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); @@ -685,6 +690,10 @@ public void openAddAccount() { } } + protected void stopMediaPlayerAndHidePip() { + playbackModel.release(); + } + private void startSharedSearch(MenuItem menuItem) { SearchEvent searchEvent = new SearchEvent("", SearchRemoteOperation.SearchType.SHARED_FILTER); MainApp.showOnlyFilesOnDevice(false); 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 82dc1eaa77f4..92679038ce32 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 @@ -69,6 +69,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.getUploadFinishMessage 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.ToolbarItem @@ -236,6 +237,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. @@ -2542,18 +2546,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/activity/ManageAccountsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt index ef3526ecdcfd..b18f9965ee16 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 @@ -238,6 +238,7 @@ class ManageAccountsActivity : } override fun showFirstRunActivity() { + stopMediaPlayerAndHidePip() val intent = Intent(applicationContext, FirstRunActivity::class.java).apply { putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true) } @@ -247,6 +248,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/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 9aa3702d5b12..4776aaab34bf 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 @@ -594,6 +594,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 dc00b032e3c7..6d2bae82b845 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 d11e13818f2a..281a16397a7a 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 @@ -45,6 +46,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/adapter/OCShareToOCFileConverter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt index affc513870b7..77ff328a5a36 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 @@ -49,6 +49,7 @@ object OCShareToOCFileConverter { note = firstShare.note fileId = firstShare.fileSource remoteId = firstShare.remoteId.toString() + localId = firstShare.fileSource // use first share timestamp as timestamp firstShareTimestamp = shares.minOf { it.sharedDate * MILLIS_PER_SECOND } // don't have file length or mod timestamp 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. */ 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 2489b4d555d0..bac1db11a035 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 @@ -1512,6 +1512,10 @@ public OCFile getCurrentFile() { return mFile; } + public SearchType getCurrentSearchType() { + return currentSearchType; + } + /** * Calls {@link OCFileListFragment#listDirectory(OCFile, boolean, 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 ae065d33d73a..bee7d93751b4 100644 --- a/app/src/main/res/layout/grid_item.xml +++ b/app/src/main/res/layout/grid_item.xml @@ -230,5 +230,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 619f42a3231e..a06f35f8469e 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/colors.xml b/app/src/main/res/values/colors.xml index 36d7459ecdaf..8f8a2d3590d0 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -89,4 +89,15 @@ #A5A5A5 #F7F9FF + + + @color/color_accent + #111111 + @color/white + @color/white + @color/white + @color/white + #21000000 + #21000000 + #979797 diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 1394d2559fda..181f866fab4a 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -160,4 +160,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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec872c14b6ff..e46092930bbe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1465,4 +1465,7 @@ Sort folders before files Sort favorites first Files + Source not found + Close + Modified: %s diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 17cdbaabca4a..48048eaa75de 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -473,4 +473,31 @@ @style/Theme.ownCloud.Launcher + + + + + + 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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9c83224b60a..727ab0ffb303 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ junit = "4.13.2" junitVersion = "1.3.0" juniversalchardetVersion = "2.5.0" kotlin = "2.2.21" +kotlinxCoroutinesVersion = "1.10.2" kotlinxSerializationJson = "1.9.0" ksp = "2.3.1" leakcanary = "2.14" @@ -140,6 +141,9 @@ jackrabbit-webdav = { module = "org.apache.jackrabbit:jackrabbit-webdav", versio json = { module = "org.json:json", version.ref = "jsonVersion" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardetVersion" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +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" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" } nnio = { module = "org.lukhnos:nnio", version.ref = "nnioVersion" } org-jbundle-util-osgi-wrapped-org-apache-http-client = { module = "org.jbundle.util.osgi.wrapped:org.jbundle.util.osgi.wrapped.org.apache.http.client", version.ref = "orgJbundleUtilOsgiWrappedOrgApacheHttpClientVersion" } @@ -187,6 +191,7 @@ media3-datasource = { module = "androidx.media3:media3-datasource-okhttp", versi media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } # Room room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" } @@ -238,7 +243,7 @@ work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRunti work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" } [bundles] -media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource"] +media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource", "media3-common"] espresso = ["espresso-core", "espresso-contrib", "espresso-web", "espresso-accessibility", "espresso-intents", "espresso-idling-resource"] ui = ["appcompat", "webkit", "cardview", "exifinterface", "fragment-ktx"] markdown-rendering = [