diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 48845975b56e..901d33abb3f6 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -527,7 +527,6 @@ dependencies {
// region Kotlin
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.core)
- implementation(libs.kotlinx.coroutines.guava)
// endregion
// region Stateless
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dec0531b00fc..bef21720c2e5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -385,13 +385,17 @@
android:theme="@style/PreferenceTheme" />
+ android:supportsPictureInPicture="true"
+ android:theme="@style/Theme.ownCloud.NoActionBar" />
()
@@ -72,31 +75,68 @@ class PlaybackModel @Inject constructor(
private val playerListener = PlaybackModelPlayerListener(
checkProgressPeriodicAction,
this::notifyPlaybackUpdate,
- this::onPlaybackError
+ this::onPlaybackError,
+ this::onPlaylistChanged
)
private val controllerListener = object : MediaController.Listener {
override fun onDisconnected(controller: MediaController) {
controller.removeListener(playerListener)
- controllerScope?.cancel()
+ if (this@PlaybackModel.controller === controller) {
+ this@PlaybackModel.controller = null
+ }
+ videoSurfaceView = null
+ invalidateCurrentFiles()
+ stopFilesFlow()
checkProgressPeriodicAction.stop()
notifyPlaybackUpdate()
}
}
private var controllerScope: CoroutineScope? = null
- private var controller: Player? = null
+ private var controller: MediaController? = null
+ private var filesFlowJob: Job? = null
private var mediaSession: MediaSession? = null
+ var onPictureInPictureClose: (() -> Unit)? = null
+
+ private var videoSurfaceView: SurfaceView? = null
+
+ private var cachedCurrentFiles: List? = null
+ private var cachedMediaIds: List? = null
+
val state: PlaybackState?
- get() = controller?.toPlaybackState()
+ get() = controller?.toPlaybackState(currentFiles())
+
+ private fun currentFiles(): List = cachedCurrentFiles
+ ?: (controller?.readCurrentFiles() ?: emptyList()).also { cachedCurrentFiles = it }
+
+ private fun onPlaylistChanged() {
+ val mediaIds = controller?.readMediaIds() ?: emptyList()
+ if (mediaIds == cachedMediaIds) return
+
+ cachedMediaIds = mediaIds
+ cachedCurrentFiles = null
+ }
+
+ private fun invalidateCurrentFiles() {
+ cachedMediaIds = null
+ cachedCurrentFiles = null
+ }
fun getMediaSession(): MediaSession = mediaSession ?: mediaSessionFactory.create().also {
mediaSession = it
}
suspend fun start() {
+ if (controller?.isConnected == true) {
+ return
+ }
+
+ releaseController()
+
+ videoSurfaceView = null
val sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java))
controller = MediaController.Builder(context, sessionToken)
.setListener(controllerListener)
@@ -106,12 +146,51 @@ class PlaybackModel @Inject constructor(
addListener(playerListener)
setRepeatMode(playbackSettings.repeatMode)
shuffleModeEnabled = playbackSettings.isShuffle
- controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
}
+ controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ }
+
+ private fun releaseController() {
+ stopFilesFlow()
+ checkProgressPeriodicAction.stop()
+ invalidateCurrentFiles()
+
+ val current = controller ?: return
+ controller = null
+ current.removeListener(playerListener)
+ current.release()
+ }
+
+ private fun stopFilesFlow() {
+ filesFlowJob?.cancel()
+ filesFlowJob = null
+ controllerScope?.cancel()
+ controllerScope = null
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun ListenableFuture.await(): T = suspendCancellableCoroutine { cont ->
+ addListener(
+ {
+ try {
+ cont.resume(get())
+ } catch (e: ExecutionException) {
+ cont.resumeWithException(e.cause ?: e)
+ } catch (e: Exception) {
+ cont.resumeWithException(e)
+ }
+ },
+ Runnable::run
+ )
+
+ cont.invokeOnCancellation {
+ cancel(false)
+ }
}
fun setFilesFlow(filesFlow: Flow) {
- controllerScope?.launch {
+ filesFlowJob?.cancel()
+ filesFlowJob = controllerScope?.launch {
filesFlow
.catch {
notifyPlaybackError(it)
@@ -127,36 +206,53 @@ class PlaybackModel @Inject constructor(
return
}
- controller?.let { controller ->
- val currentFile = controller.currentMediaItem?.mediaMetadata?.playbackFile
- val mediaItems = files.list.map { it.toMediaItem() }
-
- if (currentFile == null) {
- controller.setMediaItems(mediaItems)
- } else if (files.list.any { it.id == currentFile.id }) {
- controller.updateMediaItems(mediaItems)
- } else {
- val nextFileIndex = getNextFileIndex(files, currentFile)
- controller.setMediaItems(mediaItems, nextFileIndex, 0)
- }
+ controller?.let { applyFiles(it, files) }
+ }
+
+ private fun applyFiles(controller: MediaController, files: PlaybackFiles) {
+ val mediaItems = files.list.map { it.toMediaItem() }
+
+ if (controller.readMediaIds() == mediaItems.map { it.mediaId }) {
+ return
+ }
+
+ val currentFile = controller.currentMediaItem?.mediaMetadata?.playbackFile
- controller.prepare()
+ when {
+ currentFile == null -> controller.setMediaItems(mediaItems)
+ files.list.any { it.id == currentFile.id } -> controller.updateMediaItems(mediaItems)
+ else -> controller.setMediaItems(mediaItems, getNextFileIndex(files, currentFile), 0)
}
+
+ controller.prepare()
}
private fun getNextFileIndex(files: PlaybackFiles, currentFile: PlaybackFile): Int = (files.list + currentFile)
.sortedWith(files.comparator)
.indexOfFirst { it.id == currentFile.id }
- .let { if (it in 0..files.list.lastIndex) it else 0 }
+ .let { if (it in files.list.indices) it else 0 }
fun release() {
- controller?.release()
+ videoSurfaceView = null
+ releaseController()
mediaSession?.player?.release()
mediaSession?.release()
mediaSession = null
+ notifyPlaybackUpdate()
+ }
+
+ fun clearVideoSurfaceView(surfaceView: SurfaceView) {
+ if (videoSurfaceView === surfaceView) {
+ setVideoSurfaceView(null)
+ }
}
fun setVideoSurfaceView(surfaceView: SurfaceView?) {
+ if (videoSurfaceView === surfaceView) {
+ return
+ }
+
+ videoSurfaceView = surfaceView
controller?.setVideoSurfaceView(surfaceView)
}
@@ -188,13 +284,6 @@ class PlaybackModel @Inject constructor(
}
}
- fun playPrevious() {
- controller?.run {
- seekToPreviousMediaItem()
- prepare()
- }
- }
-
fun seekToPosition(positionInMilliseconds: Long) {
controller?.seekTo(positionInMilliseconds)
}
@@ -229,14 +318,21 @@ class PlaybackModel @Inject constructor(
}
private fun notifyPlaybackUpdate() {
- val currentState = state ?: return
- for (i in 0 until listeners.size) {
+ val currentState = state ?: releasedState()
+ for (i in listeners.indices) {
listeners.getOrNull(i)?.onPlaybackUpdate(currentState)
}
}
+ private fun releasedState(): PlaybackState = PlaybackState(
+ currentFiles = emptyList(),
+ currentItemState = null,
+ repeatMode = playbackSettings.repeatMode,
+ shuffle = playbackSettings.isShuffle
+ )
+
private fun notifyPlaybackError(error: Throwable) {
- for (i in 0 until listeners.size) {
+ for (i in listeners.indices) {
listeners.getOrNull(i)?.onPlaybackError(error)
}
}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt
index 9c7ee00b7218..e026579e314d 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt
@@ -23,7 +23,8 @@ import com.nextcloud.client.player.util.PeriodicAction
class PlaybackModelPlayerListener(
private val checkProgressPeriodicAction: PeriodicAction,
private val onPlaybackUpdate: () -> Unit,
- private val onPlaybackError: (Throwable) -> Unit
+ private val onPlaybackError: (Throwable) -> Unit,
+ private val onPlaylistChanged: () -> Unit
) : Player.Listener {
companion object {
@@ -35,6 +36,9 @@ class PlaybackModelPlayerListener(
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
+ if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
+ onPlaylistChanged()
+ }
onPlaybackUpdate()
}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt
index 99fe41b8ab0f..b84e1e2ba14f 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt
@@ -16,20 +16,26 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSession.ControllerInfo
import androidx.media3.session.MediaSessionService
-import com.nextcloud.client.player.media3.common.playbackFile
import com.nextcloud.client.player.model.file.PlaybackFileType
import com.nextcloud.client.player.ui.PlayerActivity
+import com.nextcloud.client.player.util.PlayerUtil.playbackFile
import dagger.android.AndroidInjection
import javax.inject.Inject
@UnstableApi
class PlaybackService : MediaSessionService() {
+ companion object {
+ private const val SESSION_ACTIVITY_REQUEST_CODE = 1
+ }
+
@Inject
lateinit var playbackModel: PlaybackModel
private var bindingCount: Int = 0
+ private var sessionActivityFileType: PlaybackFileType? = null
+
override fun onCreate() {
super.onCreate()
AndroidInjection.inject(this)
@@ -39,7 +45,7 @@ class PlaybackService : MediaSessionService() {
override fun onGetSession(controllerInfo: ControllerInfo): MediaSession? = playbackModel.getMediaSession()
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
- createSessionActivity(session.player.currentMediaItem)?.let(session::setSessionActivity)
+ updateSessionActivity(session)
super.onUpdateNotification(session, startInForegroundRequired)
}
@@ -70,19 +76,29 @@ class PlaybackService : MediaSessionService() {
super.onDestroy()
}
- private fun createSessionActivity(currentMediaItem: MediaItem?): PendingIntent? {
- val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null
- val fileType = PlaybackFileType.entries
- .firstOrNull { currentFile.mimeType.startsWith(it.value, ignoreCase = true) }
- ?: throw IllegalArgumentException("Unsupported file type: ${currentFile.mimeType}")
+ private fun updateSessionActivity(session: MediaSession) {
+ val fileType = session.player.currentMediaItem.playbackFileType() ?: return
+ if (fileType == sessionActivityFileType) {
+ return
+ }
- val intent = PlayerActivity.createIntent(this, fileType)
- val requestCode = System.currentTimeMillis().toInt()
+ sessionActivityFileType = fileType
+ session.setSessionActivity(createSessionActivity(fileType))
+ }
+
+ private fun MediaItem?.playbackFileType(): PlaybackFileType? {
+ val mimeType = this?.mediaMetadata?.playbackFile?.mimeType ?: return null
+ return PlaybackFileType.entries.firstOrNull { mimeType.startsWith(it.value, ignoreCase = true) }
+ }
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE)
+ private fun createSessionActivity(fileType: PlaybackFileType): PendingIntent {
+ val intent = PlayerActivity.createIntent(this, fileType)
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
- PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT)
+ PendingIntent.FLAG_UPDATE_CURRENT
}
+
+ return PendingIntent.getActivity(this, SESSION_ACTIVITY_REQUEST_CODE, intent, flags)
}
}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt
deleted file mode 100644
index 08f49811c550..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.media3
-
-import androidx.media3.common.Player
-import com.nextcloud.client.player.media3.common.playbackFile
-import com.nextcloud.client.player.model.file.PlaybackFile
-import com.nextcloud.client.player.model.state.PlaybackItemMetadata
-import com.nextcloud.client.player.model.state.PlaybackItemState
-import com.nextcloud.client.player.model.state.PlaybackState
-import com.nextcloud.client.player.model.state.PlayerState
-import com.nextcloud.client.player.model.state.RepeatMode
-import com.nextcloud.client.player.model.state.VideoSize
-
-fun Player.toPlaybackState(): PlaybackState = PlaybackState(
- currentFiles = getCurrentFiles(),
- currentItemState = getCurrentItemState(),
- repeatMode = mapRepeatMode(),
- shuffle = shuffleModeEnabled
-)
-
-private fun Player.getCurrentFiles(): List = buildList {
- for (i in 0 until mediaItemCount) {
- val mediaItem = getMediaItemAt(i)
- val playbackFile = mediaItem.mediaMetadata.playbackFile
- playbackFile?.let(::add)
- }
-}
-
-private fun Player.getCurrentItemState(): PlaybackItemState? {
- val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null
- return PlaybackItemState(
- file = currentFile,
- playerState = mapPlayerState(),
- metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null,
- videoSize = mapVideoSize(),
- currentTimeInMilliseconds = currentPosition,
- maxTimeInMilliseconds = duration
- )
-}
-
-private fun Player.mapPlayerState(): PlayerState = when (playbackState) {
- Player.STATE_IDLE -> PlayerState.IDLE
- Player.STATE_ENDED -> PlayerState.COMPLETED
- Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED
- else -> PlayerState.NONE
-}
-
-private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata(
- title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(),
- artist = mediaMetadata.artist,
- album = mediaMetadata.albumTitle,
- genre = mediaMetadata.genre,
- year = mediaMetadata.recordingYear,
- description = mediaMetadata.description,
- artworkData = mediaMetadata.artworkData,
- artworkUri = mediaMetadata.artworkUri?.toString()
-)
-
-private fun Player.mapVideoSize(): VideoSize? = videoSize
- .takeIf { it.width > 0 && it.height > 0 }
- ?.let { VideoSize(width = it.width, height = it.height) }
-
-private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) {
- Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE
- Player.REPEAT_MODE_ALL -> RepeatMode.ALL
- else -> RepeatMode.OFF
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt
deleted file mode 100644
index 9196991d9809..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.media3.common
-
-import androidx.media3.common.MediaItem
-import androidx.media3.common.MediaMetadata
-import com.nextcloud.client.player.model.file.PlaybackFile
-
-fun PlaybackFile.toMediaItem(): MediaItem = MediaItem
- .Builder()
- .setMediaId(id)
- .setUri(uri)
- .setMediaMetadata(MediaMetadata.Builder().setExtras(this).build())
- .setMimeType(mimeType)
- .build()
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt
deleted file mode 100644
index 92011c2d83c7..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.media3.common
-
-import android.os.Bundle
-import androidx.media3.common.MediaMetadata
-import com.nextcloud.client.player.model.file.PlaybackFile
-
-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/Player.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt
deleted file mode 100644
index 4680242237b2..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.media3.common
-
-import androidx.media3.common.MediaItem
-import androidx.media3.common.Player
-import com.nextcloud.client.player.model.state.RepeatMode
-
-fun Player.indexOfFirst(satisfies: (MediaItem) -> Boolean): Int {
- for (index in 0..) {
- val oldCurrentMediaItemIndex = currentMediaItemIndex
- .takeIf { it >= 0 }
-
- val newCurrentMediaItemIndex = currentMediaItem
- ?.mediaId
- ?.let { currentMediaId -> newMediaItems.indexOfFirst { it.mediaId == currentMediaId } }
- ?.takeIf { it >= 0 }
-
- if (oldCurrentMediaItemIndex != null && newCurrentMediaItemIndex != null) {
- if (oldCurrentMediaItemIndex < mediaItemCount - 1) {
- removeMediaItems(oldCurrentMediaItemIndex + 1, mediaItemCount)
- }
- if (newCurrentMediaItemIndex < newMediaItems.size - 1) {
- val itemsToAdd = newMediaItems.subList(newCurrentMediaItemIndex + 1, newMediaItems.size)
- addMediaItems(itemsToAdd)
- }
- if (oldCurrentMediaItemIndex > 0) {
- removeMediaItems(0, oldCurrentMediaItemIndex)
- }
- if (newCurrentMediaItemIndex > 0) {
- val itemsToAdd = newMediaItems.subList(0, newCurrentMediaItemIndex)
- addMediaItems(0, itemsToAdd)
- }
- replaceMediaItem(newCurrentMediaItemIndex, newMediaItems[newCurrentMediaItemIndex])
- } else {
- setMediaItems(newMediaItems)
- }
-}
-
-fun Player.setRepeatMode(mode: RepeatMode) {
- repeatMode = when (mode) {
- RepeatMode.SINGLE -> Player.REPEAT_MODE_ONE
- RepeatMode.ALL -> Player.REPEAT_MODE_ALL
- RepeatMode.OFF -> Player.REPEAT_MODE_OFF
- }
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt
index 95f74088c8b5..6a54455b05ad 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt
@@ -12,7 +12,7 @@ import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
-import com.nextcloud.client.player.model.file.getRemoteFileId
+import com.nextcloud.client.player.util.PlayerUtil.getRemoteFileId
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.files.StreamMediaFileOperation
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt
index 40c0af28912f..f0998a79343e 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt
@@ -19,12 +19,13 @@ import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.network.ClientFactory
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.FileDataStorageManager
+import dagger.Lazy
import javax.inject.Inject
@UnstableApi
class PlaybackDataSourceFactory @Inject constructor(
private val context: Context,
- private val cache: Cache,
+ private val cache: Lazy,
private val fileDataStorageManager: FileDataStorageManager,
private val clientFactory: ClientFactory,
private val accountManager: UserAccountManager
@@ -32,7 +33,7 @@ class PlaybackDataSourceFactory @Inject constructor(
override fun createDataSource(): DataSource = CacheDataSource.Factory()
.setUpstreamDataSourceFactory(createUpstreamDataSourceFactory())
- .setCache(cache)
+ .setCache(cache.get())
.createDataSource()
private fun createUpstreamDataSourceFactory() = DataSource.Factory {
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt
index 6c6fb1feb28b..e28d31cde193 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt
@@ -7,12 +7,12 @@
package com.nextcloud.client.player.media3.resumption
+import com.nextcloud.client.player.model.file.PlaybackCollection
import com.nextcloud.client.player.model.file.PlaybackFileType
-import com.owncloud.android.ui.fragment.SearchType
data class PlaybackResumptionConfig(
val currentFileId: String,
val folderId: Long,
val fileType: PlaybackFileType,
- val searchType: SearchType?
+ val collection: PlaybackCollection
)
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt
index e6f05124c57f..dc3cfe97134d 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt
@@ -9,8 +9,8 @@ package com.nextcloud.client.player.media3.resumption
import android.content.Context
import androidx.core.content.edit
+import com.nextcloud.client.player.model.file.PlaybackCollection
import com.nextcloud.client.player.model.file.PlaybackFileType
-import com.owncloud.android.ui.fragment.SearchType
import javax.inject.Inject
class PlaybackResumptionConfigStore @Inject constructor(private val context: Context) {
@@ -19,7 +19,7 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con
private const val CURRENT_FILE_ID_KEY = "current_file_id"
private const val FOLDER_ID_KEY = "folder_id"
private const val FILE_TYPE_KEY = "file_type"
- private const val SEARCH_TYPE_KEY = "search_type"
+ private const val COLLECTION_KEY = "collection"
}
private val preferences by lazy {
@@ -30,20 +30,21 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con
val currentFileId = preferences.getString(CURRENT_FILE_ID_KEY, null)
val folderId = preferences.getLong(FOLDER_ID_KEY, 0L)
val fileType = preferences.getString(FILE_TYPE_KEY, null)?.let(::playbackFileType)
- val searchType = preferences.getString(SEARCH_TYPE_KEY, null)?.let(::searchType)
+ val collection = preferences.getString(COLLECTION_KEY, null)?.let(::playbackCollection)
+ ?: PlaybackCollection.FOLDER
return if (currentFileId != null && folderId != 0L && fileType != null) {
- PlaybackResumptionConfig(currentFileId, folderId, fileType, searchType)
+ PlaybackResumptionConfig(currentFileId, folderId, fileType, collection)
} else {
null
}
}
- fun saveConfig(currentFileId: String, folderId: Long, fileType: PlaybackFileType, searchType: SearchType?) {
+ fun saveConfig(currentFileId: String, folderId: Long, fileType: PlaybackFileType, collection: PlaybackCollection) {
preferences.edit {
putString(CURRENT_FILE_ID_KEY, currentFileId)
putLong(FOLDER_ID_KEY, folderId)
putString(FILE_TYPE_KEY, fileType.value)
- putString(SEARCH_TYPE_KEY, searchType?.name)
+ putString(COLLECTION_KEY, collection.name)
}
}
@@ -57,5 +58,6 @@ class PlaybackResumptionConfigStore @Inject constructor(private val context: Con
it.value == value
}
- private fun searchType(name: String): SearchType? = SearchType.entries.firstOrNull { it.name == name }
+ private fun playbackCollection(name: String): PlaybackCollection? =
+ PlaybackCollection.entries.firstOrNull { it.name == name }
}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt
index b3a6d281411b..92a6e3d56017 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt
@@ -10,10 +10,10 @@ package com.nextcloud.client.player.media3.resumption
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition
import com.nextcloud.client.player.media3.PlaybackModel
-import com.nextcloud.client.player.media3.common.toMediaItem
import com.nextcloud.client.player.model.file.PlaybackFile
import com.nextcloud.client.player.model.file.PlaybackFilesRepository
-import com.nextcloud.client.player.model.file.getPlaybackUri
+import com.nextcloud.client.player.util.PlayerUtil.getPlaybackUri
+import com.nextcloud.client.player.util.PlayerUtil.toMediaItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
@@ -29,9 +29,9 @@ class PlaybackResumptionLauncher @Inject constructor(
) {
suspend fun launch(): MediaItemsWithStartPosition = runCatching {
- val (currentFileId, folderId, fileType, searchType) = playbackResumptionConfigStore.loadConfig()
+ val (currentFileId, folderId, fileType, collection) = playbackResumptionConfigStore.loadConfig()
?: throw IllegalStateException("Playback resumption config is null")
- val playbackFilesFlow = playbackFilesRepository.observe(folderId, fileType, searchType)
+ val playbackFilesFlow = playbackFilesRepository.observe(folderId, fileType, collection)
val playbackFiles = playbackFilesFlow.first().list.ifEmpty {
throw IllegalStateException("Playback files are empty")
}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt
index cd49ae45d214..05b8c4c4a6f2 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt
@@ -20,9 +20,9 @@ import androidx.media3.datasource.DataSourceBitmapLoader
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.ListeningExecutorService
import com.google.common.util.concurrent.MoreExecutors
-import com.nextcloud.client.player.media3.common.playbackFile
-import com.nextcloud.client.player.model.ThumbnailLoader
+import com.nextcloud.client.player.model.PlayerThumbnailLoader
import com.nextcloud.client.player.model.file.PlaybackFile
+import com.nextcloud.client.player.util.PlayerUtil.playbackFile
import com.owncloud.android.R
import com.owncloud.android.utils.MimeTypeUtil
import java.util.concurrent.Callable
@@ -32,8 +32,8 @@ import javax.inject.Inject
@UnstableApi
class MediaSessionBitmapLoader @Inject constructor(
private val context: Context,
- private val thumbnailLoader: ThumbnailLoader
-) : BitmapLoader by DataSourceBitmapLoader(context) {
+ private val playerThumbnailLoader: PlayerThumbnailLoader
+) : BitmapLoader by DataSourceBitmapLoader.Builder(context).build() {
companion object {
private const val THUMBNAIL_TARGET_SIZE = 160
@@ -82,14 +82,14 @@ class MediaSessionBitmapLoader @Inject constructor(
private fun getBitmapFromMetadata(metadata: MediaMetadata, fileId: String?): Bitmap? {
val model = metadata.artworkData ?: metadata.artworkUri ?: return null
return runCatching {
- thumbnailLoader.load(context, model, fileId, thumbnailSize, thumbnailSize).get()
+ playerThumbnailLoader.load(model, fileId, thumbnailSize, thumbnailSize).get()
}.getOrElse {
null
}
}
private fun getBitmapForFile(file: PlaybackFile): Bitmap? = runCatching {
- thumbnailLoader.load(context, file, thumbnailSize, thumbnailSize).get()
+ playerThumbnailLoader.load(file, thumbnailSize, thumbnailSize).get()
}.getOrElse {
null
}
diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt
index cd94c01708f4..ffd8d296c599 100644
--- a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt
+++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt
@@ -16,10 +16,12 @@ import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
-import com.nextcloud.client.player.media3.resumption.PlaybackResumptionLauncher
+import com.google.common.util.concurrent.SettableFuture
import com.nextcloud.client.player.media3.PlaybackModel
+import com.nextcloud.client.player.media3.resumption.PlaybackResumptionLauncher
+import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.guava.future
+import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Provider
@@ -45,7 +47,7 @@ class MediaSessionCallback @Inject constructor(
.buildUpon()
.add(SessionCommand(CLOSE_ACTION, Bundle.EMPTY))
.build()
- return ConnectionResult.AcceptedResultBuilder(session)
+ return ConnectionResult.AcceptedResultBuilder(session, controller)
.setAvailableSessionCommands(sessionCommands)
.build()
}
@@ -62,8 +64,22 @@ class MediaSessionCallback @Inject constructor(
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
+ @OptIn(DelicateCoroutinesApi::class)
+ @Suppress("TooGenericExceptionCaught")
override fun onPlaybackResumption(
mediaSession: MediaSession,
- controller: MediaSession.ControllerInfo
- ): ListenableFuture = GlobalScope.future { playbackResumptionLauncher.launch() }
+ controller: MediaSession.ControllerInfo,
+ isForPlayback: Boolean
+ ): ListenableFuture {
+ val future = SettableFuture.create()
+ GlobalScope.launch {
+ try {
+ val result = playbackResumptionLauncher.launch()
+ future.set(result)
+ } catch (e: Exception) {
+ future.setException(e)
+ }
+ }
+ return future
+ }
}
diff --git a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/PlayerThumbnailLoader.kt
similarity index 65%
rename from app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt
rename to app/src/main/java/com/nextcloud/client/player/model/PlayerThumbnailLoader.kt
index 74b2ebeb0c23..f4cf34b3955e 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/PlayerThumbnailLoader.kt
@@ -26,30 +26,34 @@ import java.util.concurrent.Future
import javax.inject.Inject
import kotlin.coroutines.resume
-class ThumbnailLoader @Inject constructor(clientFactory: ClientFactory, userAccountManager: UserAccountManager) {
+class PlayerThumbnailLoader @Inject constructor(
+ context: Context,
+ clientFactory: ClientFactory,
+ userAccountManager: UserAccountManager
+) {
+ private val appContext = context.applicationContext
private val client by lazy { clientFactory.createNextcloudClient(userAccountManager.user) }
- suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? =
- withContext(Dispatchers.IO) {
- suspendCancellableCoroutine { continuation ->
- runCatching {
- val future = load(context, file, width, height)
- continuation.invokeOnCancellation { future.cancel(true) }
- continuation.resume(future.get())
- }.onFailure {
- if (it is CancellationException) throw it
- continuation.resume(null)
- }
+ suspend fun await(file: PlaybackFile, width: Int, height: Int): Bitmap? = withContext(Dispatchers.IO) {
+ suspendCancellableCoroutine { continuation ->
+ runCatching {
+ val future = load(file, width, height)
+ continuation.invokeOnCancellation { future.cancel(true) }
+ continuation.resume(future.get())
+ }.onFailure {
+ if (it is CancellationException) throw it
+ continuation.resume(null)
}
}
+ }
- fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future {
+ fun load(file: PlaybackFile, width: Int, height: Int): Future {
val url = createUrl(file, width, height)
- return load(context, url, file.id, width, height)
+ return load(url, file.id, width, height)
}
- fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future = Glide
- .with(context)
+ fun load(model: Any, fileId: String?, width: Int, height: Int): Future = Glide
+ .with(appContext)
.asBitmap()
.load(model)
.signature(ObjectKey(fileId ?: model.toString()))
diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackCollection.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackCollection.kt
new file mode 100644
index 000000000000..7155f2f16e98
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackCollection.kt
@@ -0,0 +1,33 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.player.model.file
+
+import com.owncloud.android.datamodel.VirtualFolderType
+import com.owncloud.android.ui.fragment.SearchType
+
+enum class PlaybackCollection {
+ FOLDER,
+ FAVORITES,
+ GALLERY,
+ SHARED,
+ ALBUM
+}
+
+fun SearchType?.toPlaybackCollection(): PlaybackCollection = when (this) {
+ SearchType.FAVORITE_SEARCH -> PlaybackCollection.FAVORITES
+ SearchType.GALLERY_SEARCH -> PlaybackCollection.GALLERY
+ SearchType.SHARED_FILTER -> PlaybackCollection.SHARED
+ else -> PlaybackCollection.FOLDER
+}
+
+fun VirtualFolderType?.toPlaybackCollection(): PlaybackCollection = when (this) {
+ VirtualFolderType.FAVORITE -> PlaybackCollection.FAVORITES
+ VirtualFolderType.GALLERY -> PlaybackCollection.GALLERY
+ VirtualFolderType.ALBUM -> PlaybackCollection.ALBUM
+ else -> PlaybackCollection.FOLDER
+}
diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt
index 682f085b181a..1fbbd007bc3c 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt
@@ -7,8 +7,9 @@
package com.nextcloud.client.player.model.file
-import java.io.Serializable
+import kotlinx.serialization.Serializable
+@Serializable
data class PlaybackFile(
val id: String,
val uri: String,
@@ -17,6 +18,6 @@ data class PlaybackFile(
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
deleted file mode 100644
index 9993f89172c3..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.model.file
-
-import com.owncloud.android.datamodel.OCFile
-import com.owncloud.android.lib.resources.shares.OCShare
-import com.owncloud.android.utils.MimeTypeUtil
-import java.io.File
-
-fun OCFile.toPlaybackFile() = PlaybackFile(
- id = localId.toString(),
- uri = getPlaybackUri().toString(),
- name = fileName,
- mimeType = mimeType,
- contentLength = fileLength,
- lastModified = modificationTimestamp,
- isFavorite = isFavorite
-)
-
-fun OCShare.toPlaybackFile() = PlaybackFile(
- id = fileSource.toString(),
- uri = getPlaybackUri().toString(),
- name = path?.let { File(it).name } ?: "",
- mimeType = getMimeType(),
- contentLength = -1L,
- lastModified = sharedDate * 1000L,
- isFavorite = isFavorite
-)
-
-private fun OCShare.getMimeType(): String = mimetype
- ?.takeIf { it.isNotEmpty() }
- ?: path?.let { MimeTypeUtil.getMimeTypeFromPath(it) }
- ?: ""
diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt
deleted file mode 100644
index 562a2e3a2d53..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.model.file
-
-import android.net.Uri
-import com.owncloud.android.datamodel.OCFile
-import com.owncloud.android.lib.resources.shares.OCShare
-
-const val REMOTE_FILE_SCHEME = "remoteFile"
-
-fun OCFile.getPlaybackUri(): Uri = getPlaybackUri(localId)
-
-fun OCShare.getPlaybackUri(): Uri = getPlaybackUri(fileSource)
-
-fun getPlaybackUri(fileId: Long): Uri = Uri.Builder()
- .scheme(REMOTE_FILE_SCHEME)
- .authority("")
- .appendPath(fileId.toString())
- .build()
-
-fun Uri.getRemoteFileId(): Long? = scheme
- ?.takeIf { it == REMOTE_FILE_SCHEME }
- ?.let { pathSegments.firstOrNull()?.toLongOrNull() }
diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt
index 4465abb8a212..33c6b918690f 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt
@@ -20,6 +20,10 @@ sealed interface PlaybackFilesComparator : Comparator {
override fun compare(a: PlaybackFile, b: PlaybackFile): Int = AlphanumericComparator.compare(a.name, b.name)
}
+ object ALBUM : PlaybackFilesComparator {
+ override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified }
+ }
+
object GALLERY : PlaybackFilesComparator {
override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified }
}
diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt
index e94a9d7e3be7..bd35c0afd2ab 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt
@@ -10,12 +10,13 @@ package com.nextcloud.client.player.model.file
import android.content.ContentUris
import android.content.Context
import android.net.Uri
-import com.nextcloud.client.player.util.observeContentChanges
+import com.nextcloud.client.player.util.PlayerUtil.observeContentChanges
+import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile
import com.nextcloud.client.preferences.AppPreferences
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.VirtualFolderType
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
-import com.owncloud.android.ui.fragment.SearchType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
@@ -35,12 +36,25 @@ class PlaybackFilesRepository @Inject constructor(
private val preferences: AppPreferences
) {
- fun observe(folderId: Long, fileType: PlaybackFileType, searchType: SearchType?): Flow =
- when (searchType) {
- SearchType.FAVORITE_SEARCH -> observeFavoritePlaybackFiles(fileType)
- SearchType.GALLERY_SEARCH -> observeGalleryPlaybackFiles(fileType)
- SearchType.SHARED_FILTER -> observeSharedPlaybackFiles(fileType)
- else -> observeFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice())
+ fun observe(folderId: Long, fileType: PlaybackFileType, collection: PlaybackCollection): Flow =
+ when (collection) {
+ PlaybackCollection.FAVORITES -> observeFavoritePlaybackFiles(fileType)
+ PlaybackCollection.GALLERY -> observeGalleryPlaybackFiles(fileType)
+ PlaybackCollection.SHARED -> observeSharedPlaybackFiles(fileType)
+ PlaybackCollection.ALBUM -> observeAlbumPlaybackFiles(fileType)
+ PlaybackCollection.FOLDER -> observeFolderPlaybackFiles(folderId, fileType, MainApp.isOnlyOnDevice())
+ }
+
+ private fun observeAlbumPlaybackFiles(fileType: PlaybackFileType): Flow =
+ observeData(ProviderTableMeta.CONTENT_URI, true) {
+ withContext(Dispatchers.IO) {
+ storageManager.getVirtualFolderContent(VirtualFolderType.ALBUM, false)
+ .asSequence()
+ .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) }
+ .map { it.toPlaybackFile() }
+ .sortedWith(PlaybackFilesComparator.ALBUM)
+ .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.ALBUM) }
+ }
}
private fun observeFavoritePlaybackFiles(fileType: PlaybackFileType): Flow =
@@ -58,8 +72,10 @@ class PlaybackFilesRepository @Inject constructor(
private fun observeGalleryPlaybackFiles(fileType: PlaybackFileType): Flow =
observeData(ProviderTableMeta.CONTENT_URI, true) {
withContext(Dispatchers.IO) {
+ val mediaFolderPath = preferences.getLastSelectedMediaFolder()
storageManager.allGalleryItems
.asSequence()
+ .filter { it.remotePath.startsWith(mediaFolderPath) }
.filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) }
.map { it.toPlaybackFile() }
.sortedWith(PlaybackFilesComparator.GALLERY)
diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt
index 451f96dc10d3..d8b599ae3177 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt
@@ -7,7 +7,7 @@
package com.nextcloud.client.player.model.state
-import java.io.Serializable
+import java.util.Objects
data class PlaybackItemMetadata(
val title: CharSequence,
@@ -18,4 +18,14 @@ data class PlaybackItemMetadata(
val description: CharSequence? = null,
val artworkData: ByteArray? = null,
val artworkUri: CharSequence? = null
-) : Serializable
+) {
+ override fun equals(other: Any?): Boolean = when {
+ this === other -> true
+ other !is PlaybackItemMetadata -> false
+ else -> describedFields() == other.describedFields() && artworkData.contentEquals(other.artworkData)
+ }
+
+ override fun hashCode(): Int = Objects.hash(describedFields(), artworkData?.contentHashCode())
+
+ private fun describedFields(): List = listOf(title, artist, album, genre, year, description, artworkUri)
+}
diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt
index 735aa448f282..6275ee12ec17 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt
@@ -8,7 +8,6 @@
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,
@@ -17,4 +16,4 @@ data class PlaybackItemState(
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
index 6ec65e6e92cb..4d8930922278 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt
@@ -8,11 +8,10 @@
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
index 1caf340706f2..d4c5d14e782e 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt
@@ -7,9 +7,7 @@
package com.nextcloud.client.player.model.state
-import java.io.Serializable
-
-enum class PlayerState : Serializable {
+enum class PlayerState {
IDLE,
PLAYING,
PAUSED,
diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt
index 91d0a30ee140..d4ba9aeeb9d9 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt
@@ -7,9 +7,7 @@
package com.nextcloud.client.player.model.state
-import java.io.Serializable
-
-enum class RepeatMode(val id: Int) : Serializable {
+enum class RepeatMode(val id: Int) {
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
index 9e94d2b4a922..865cf132cc95 100644
--- a/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt
+++ b/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt
@@ -7,6 +7,4 @@
package com.nextcloud.client.player.model.state
-import java.io.Serializable
-
-data class VideoSize(val width: Int, val height: Int) : Serializable
+data class VideoSize(val width: Int, val height: Int)
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/MediaNavigator.kt b/app/src/main/java/com/nextcloud/client/player/ui/MediaNavigator.kt
new file mode 100644
index 000000000000..8ebdd78ec420
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/player/ui/MediaNavigator.kt
@@ -0,0 +1,19 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.player.ui
+
+interface MediaNavigator {
+
+ val hasNext: Boolean
+
+ val hasPrevious: Boolean
+
+ fun showNext()
+
+ fun showPrevious()
+}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt
index 1527c09911f5..dc7d5ebf1593 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt
@@ -7,15 +7,11 @@
package com.nextcloud.client.player.ui
-import android.app.PictureInPictureParams
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
-import android.graphics.Rect
import android.media.AudioManager
-import android.os.Build
import android.os.Bundle
-import android.util.Rational
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
@@ -30,8 +26,6 @@ import com.nextcloud.client.di.ViewModelFactory
import com.nextcloud.client.player.model.file.PlaybackFileType
import com.nextcloud.client.player.ui.audio.AudioPlayerView
import com.nextcloud.client.player.ui.video.VideoPlayerView
-import com.nextcloud.client.player.util.isPictureInPictureAllowed
-import com.nextcloud.ui.fileactions.FileAction
import com.nextcloud.ui.fileactions.FileActionsBottomSheet
import com.nextcloud.utils.extensions.getSerializableArgument
import com.owncloud.android.R
@@ -45,9 +39,6 @@ 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 {
@@ -71,10 +62,12 @@ class PlayerActivity :
private lateinit var playerView: PlayerView
- private val pipAspectRatio = Rational(PIP_ASPECT_RATIO_WIDTH, PIP_ASPECT_RATIO_HEIGHT)
+ private val pictureInPicture by lazy { VideoPictureInPicture(this, playbackModel, autoEnter = true) }
private var onBackPressedCallback: OnBackPressedCallback? = null
+ private var keepPlaybackAliveOnFinish = false
+
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -89,14 +82,12 @@ class PlayerActivity :
.launchIn(lifecycleScope)
onBackPressedCallback = onBackPressedDispatcher.addCallback(this) {
- val isVideoPlayback = playbackFileType == PlaybackFileType.VIDEO
-
- if (isPictureInPictureAllowed() && isVideoPlayback) {
- switchToPictureInPictureMode()
- } else {
- file = file?.parentId?.let { storageManager.getFileById(it) }
- finish()
+ if (canUsePictureInPictureMode() && pictureInPicture.enter(playerView)) {
+ return@addCallback
}
+
+ file = file?.parentId?.let { storageManager.getFileById(it) }
+ finish()
}
volumeControlStream = AudioManager.STREAM_MUSIC
@@ -106,7 +97,6 @@ class PlayerActivity :
super.onNewIntent(intent)
playbackFileType = intent.getPlaybackFileType()
recreatePlayerView()
- onBackPressedCallback?.isEnabled = canUsePictureInPictureMode()
}
private fun createPlayerView() {
@@ -121,6 +111,7 @@ class PlayerActivity :
private fun recreatePlayerView() {
playerView.onStop()
+ playerView.release()
createPlayerView()
playerView.onStart()
}
@@ -141,78 +132,70 @@ class PlayerActivity :
override fun onDestroy() {
super.onDestroy()
- if (isFinishing && playbackFileType == PlaybackFileType.VIDEO) {
+ playbackModel.onPictureInPictureClose = null
+ if (isFinishing && !keepPlaybackAliveOnFinish && playbackFileType == PlaybackFileType.VIDEO) {
playbackModel.release()
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
- recreatePlayerView()
+
+ val videoPlayerView = playerView as? VideoPlayerView ?: return
if (isInPictureInPictureMode) {
- (playerView as? VideoPlayerView)?.hideControls()
+ videoPlayerView.hideControls()
} else {
- (playerView as? VideoPlayerView)?.showControls()
+ videoPlayerView.showControls()
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (canUsePictureInPictureMode()) {
- switchToPictureInPictureMode()
+ pictureInPicture.enter(playerView)
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
+
+ playbackModel.onPictureInPictureClose = if (isInPictureInPictureMode) {
+ {
+ keepPlaybackAliveOnFinish = true
+ finish()
+ }
+ } else {
+ null
+ }
+
if (!isInPictureInPictureMode && lifecycle.currentState == Lifecycle.State.CREATED) {
finish() // Finish the activity if the user closes the PIP window
+ return
}
- }
-
- 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)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- it.setAutoEnterEnabled(true)
+ if (!isInPictureInPictureMode) {
+ (playerView as? VideoPlayerView)?.showControls()
}
- it.setSourceRectHint(getSourceRectHint())
- 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 canUsePictureInPictureMode(): Boolean =
+ playbackFileType == PlaybackFileType.VIDEO && pictureInPicture.isAllowed
private fun handleEvent(event: PlayerScreenEvent) {
when (event) {
- is PlayerScreenEvent.ShowFileActions -> showFileActions(event.file, event.actionIds)
+ is PlayerScreenEvent.ShowFileActions -> showFileActions(event.file, event.actionsToHide)
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)
+ is PlayerScreenEvent.ToggleFileLock -> fileOperationsHelper.toggleFileLock(event.file, event.shouldBeLocked)
+ is PlayerScreenEvent.AddFileToAlbum -> fileOperationsHelper.addFileToAlbum(listOf(event.file))
}
}
- private fun showFileActions(file: OCFile, actionIds: List) {
- val actionsToHide = FileAction.entries.map(FileAction::id).filter { it !in actionIds }
+ private fun showFileActions(file: OCFile, actionsToHide: List) {
FileActionsBottomSheet.newInstance(file, false, actionsToHide)
.setResultListener(supportFragmentManager, this) { viewModel.onFileActionChosen(file, it) }
.show(supportFragmentManager, "actions")
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt
index d9912f12c756..c316d2fbaddd 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt
@@ -13,13 +13,13 @@ import androidx.lifecycle.lifecycleScope
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.player.media3.PlaybackModel
import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore
+import com.nextcloud.client.player.model.file.PlaybackCollection
import com.nextcloud.client.player.model.file.PlaybackFileType
import com.nextcloud.client.player.model.file.PlaybackFiles
import com.nextcloud.client.player.model.file.PlaybackFilesComparator
import com.nextcloud.client.player.model.file.PlaybackFilesRepository
-import com.nextcloud.client.player.model.file.toPlaybackFile
+import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile
import com.owncloud.android.datamodel.OCFile
-import com.owncloud.android.ui.fragment.SearchType
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.concurrent.CancellationException
@@ -36,9 +36,9 @@ class PlayerLauncher @Inject constructor(
/**
* Starts playback and opens [PlayerActivity] on top of [activity].
*/
- fun launch(activity: AppCompatActivity, file: OCFile, searchType: SearchType?) {
+ fun launch(activity: AppCompatActivity, file: OCFile, collection: PlaybackCollection) {
run(activity) {
- val fileType = prepareQueue(file, searchType)
+ val fileType = prepareQueue(file, collection)
playbackModel.play()
activity.startActivity(PlayerActivity.createIntent(activity, fileType))
}
@@ -48,9 +48,9 @@ class PlayerLauncher @Inject constructor(
* Loads [file] and the media of its collection into the player without opening [PlayerActivity], so that a host
* screen can render the playback itself.
*/
- fun prepare(owner: LifecycleOwner, file: OCFile, searchType: SearchType?, autoplay: Boolean = false) {
+ fun prepare(owner: LifecycleOwner, file: OCFile, collection: PlaybackCollection, autoplay: Boolean = false) {
run(owner) {
- prepareQueue(file, searchType)
+ prepareQueue(file, collection)
if (autoplay) {
playbackModel.play()
}
@@ -67,13 +67,13 @@ class PlayerLauncher @Inject constructor(
}
}
- private suspend fun prepareQueue(file: OCFile, searchType: SearchType?): PlaybackFileType {
+ private suspend fun prepareQueue(file: OCFile, collection: PlaybackCollection): PlaybackFileType {
val fileType = PlaybackFileType.ofMimeType(file.mimeType)
- playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, searchType)
+ playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, collection)
playbackModel.start()
playbackModel.setFiles(PlaybackFiles(listOf(file.toPlaybackFile()), PlaybackFilesComparator.NONE))
- playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, searchType))
+ playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, collection))
return fileType
}
}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt
index d70435d7d34d..947e9b04c46a 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt
@@ -13,10 +13,10 @@ import androidx.annotation.AttrRes
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.nextcloud.client.player.media3.PlaybackModel
import com.nextcloud.client.player.model.file.PlaybackFile
-import com.nextcloud.client.player.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.nextcloud.client.player.util.PlayerUtil.toPlaybackFile
import com.owncloud.android.datamodel.OCFile
import dagger.android.HasAndroidInjector
import javax.inject.Inject
@@ -58,8 +58,7 @@ class PlayerProgressIndicator @JvmOverloads constructor(
}
override fun onPlaybackUpdate(state: PlaybackState) {
- val itemState = state.currentItemState
- render(itemState)
+ render(state.currentItemState)
}
fun setFile(file: OCFile) {
@@ -68,8 +67,12 @@ class PlayerProgressIndicator @JvmOverloads constructor(
}
private fun renderCurrentState() {
- val itemState = playbackModel.state?.currentItemState
- render(itemState)
+ if (playbackFile == null) {
+ visibility = GONE
+ return
+ }
+
+ render(playbackModel.state?.currentItemState)
}
private fun render(itemState: PlaybackItemState?) {
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt
index c5b0d23f066f..7f8f56c57202 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt
@@ -11,7 +11,7 @@ import com.owncloud.android.datamodel.OCFile
sealed interface PlayerScreenEvent {
- data class ShowFileActions(val file: OCFile, val actionIds: List) : PlayerScreenEvent
+ data class ShowFileActions(val file: OCFile, val actionsToHide: List) : PlayerScreenEvent
data class ShowFileDetails(val file: OCFile) : PlayerScreenEvent
@@ -24,4 +24,8 @@ sealed interface PlayerScreenEvent {
data class LaunchOpenFileIntent(val file: OCFile) : PlayerScreenEvent
data class LaunchStreamFileIntent(val file: OCFile) : PlayerScreenEvent
+
+ data class ToggleFileLock(val file: OCFile, val shouldBeLocked: Boolean) : PlayerScreenEvent
+
+ data class AddFileToAlbum(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
index 5c4fdf1f9ad3..7dc7ca7e18fa 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt
@@ -72,8 +72,9 @@ abstract class PlayerView @JvmOverloads constructor(
inflate(context, layoutRes, this)
if (!isInEditMode) {
(context.applicationContext as HasAndroidInjector).androidInjector().inject(this)
- playerPager.initialize(activity.supportFragmentManager, createFragment)
+ playerPager.initialize(activity.supportFragmentManager, activity.lifecycle, createFragment)
playerPager.onItemSelected = { playbackModel.switchToFile(it) }
+ playerControlView.navigator = playerPager
findViewById(R.id.back).setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() }
}
}
@@ -97,6 +98,11 @@ abstract class PlayerView @JvmOverloads constructor(
playerControlView.onStop()
}
+ @CallSuper
+ open fun release() {
+ playerPager.release()
+ }
+
override fun onPlaybackUpdate(state: PlaybackState) {
render(state)
}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt
index df14c565c931..640a32f02e21 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt
@@ -15,6 +15,7 @@ import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.download.FileDownloadHelper
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.player.media3.PlaybackModel
+import com.nextcloud.ui.fileactions.FileAction
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
@@ -41,28 +42,36 @@ class PlayerViewModel @Inject constructor(
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))
+ val actionsToHide = FileAction.getFilePreviewActions(file)
+ eventChannel.trySend(PlayerScreenEvent.ShowFileActions(file, actionsToHide))
}
}
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)
+
+ R.id.action_lock_file -> eventChannel.trySend(
+ PlayerScreenEvent.ToggleFileLock(file, shouldBeLocked = true)
+ )
+
+ R.id.action_unlock_file -> eventChannel.trySend(
+ PlayerScreenEvent.ToggleFileLock(file, shouldBeLocked = false)
+ )
+
+ R.id.action_add_to_album -> eventChannel.trySend(PlayerScreenEvent.AddFileToAlbum(file))
}
}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/VideoPictureInPicture.kt b/app/src/main/java/com/nextcloud/client/player/ui/VideoPictureInPicture.kt
new file mode 100644
index 000000000000..0946de7a6b19
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/player/ui/VideoPictureInPicture.kt
@@ -0,0 +1,71 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.player.ui
+
+import android.app.Activity
+import android.app.PictureInPictureParams
+import android.graphics.Rect
+import android.os.Build
+import android.util.Rational
+import android.view.View
+import com.nextcloud.client.player.media3.PlaybackModel
+import com.nextcloud.client.player.model.state.VideoSize
+import com.nextcloud.client.player.util.PlayerUtil.isPictureInPictureAllowed
+
+private const val ASPECT_RATIO_WIDTH = 16
+private const val ASPECT_RATIO_HEIGHT = 9
+private const val MIN_ASPECT_RATIO = 0.42f
+private const val MAX_ASPECT_RATIO = 2.39f
+
+class VideoPictureInPicture(
+ private val activity: Activity,
+ private val playbackModel: PlaybackModel,
+ private val autoEnter: Boolean
+) {
+
+ private val defaultAspectRatio = Rational(ASPECT_RATIO_WIDTH, ASPECT_RATIO_HEIGHT)
+
+ val isAllowed: Boolean get() = activity.isPictureInPictureAllowed()
+
+ fun enter(sourceView: View): Boolean = isAllowed && activity.enterPictureInPictureMode(createParams(sourceView))
+
+ private fun createParams(sourceView: View): PictureInPictureParams {
+ val aspectRatio = getAspectRatio(playbackModel.state?.currentItemState?.videoSize)
+ return PictureInPictureParams.Builder().let {
+ it.setAspectRatio(aspectRatio)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ it.setAutoEnterEnabled(autoEnter)
+ }
+ it.setSourceRectHint(getSourceRectHint(sourceView, aspectRatio))
+ it.build()
+ }
+ }
+
+ private fun getAspectRatio(videoSize: VideoSize?): Rational {
+ if (videoSize == null) return defaultAspectRatio
+
+ val ratio = videoSize.width.toFloat() / videoSize.height.toFloat()
+ return if (ratio < MIN_ASPECT_RATIO || ratio > MAX_ASPECT_RATIO) {
+ defaultAspectRatio
+ } else {
+ Rational(videoSize.width, videoSize.height)
+ }
+ }
+
+ private fun getSourceRectHint(sourceView: View, aspectRatio: Rational): Rect {
+ val sourceRect = Rect()
+ sourceView.getGlobalVisibleRect(sourceRect)
+ val heightHint = (sourceRect.width() / aspectRatio.toFloat()).toInt()
+ return Rect(
+ sourceRect.left,
+ sourceRect.top + (sourceRect.height() - heightHint) / 2,
+ sourceRect.right,
+ sourceRect.top + (sourceRect.height() + heightHint) / 2
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt
index 677450fe45fd..2eaa40b2b5fe 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt
@@ -11,15 +11,15 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.nextcloud.client.player.media3.PlaybackModel
-import com.nextcloud.client.player.model.ThumbnailLoader
+import com.nextcloud.client.player.model.PlayerThumbnailLoader
import com.nextcloud.client.player.model.file.PlaybackFile
import com.nextcloud.client.player.model.state.PlaybackItemMetadata
import com.nextcloud.client.player.model.state.PlaybackState
-import com.nextcloud.utils.extensions.getSerializableArgument
+import com.nextcloud.client.player.util.PlayerUtil.getPlaybackFile
+import com.nextcloud.client.player.util.PlayerUtil.putPlaybackFile
import com.owncloud.android.R
import com.owncloud.android.databinding.PlayerAudioFileFragmentBinding
import com.owncloud.android.utils.DisplayUtils
@@ -37,7 +37,7 @@ open class AudioFileFragment :
private const val DETAILS_SEPARATOR = ", "
fun createInstance(file: PlaybackFile) = AudioFileFragment().apply {
- arguments = bundleOf(ARGUMENT_FILE to file)
+ arguments = Bundle().apply { putPlaybackFile(ARGUMENT_FILE, file) }
}
}
@@ -45,7 +45,7 @@ open class AudioFileFragment :
lateinit var playbackModel: PlaybackModel
@Inject
- lateinit var thumbnailLoader: ThumbnailLoader
+ lateinit var playerThumbnailLoader: PlayerThumbnailLoader
private var _binding: PlayerAudioFileFragmentBinding? = null
private val binding get() = checkNotNull(_binding) { "Binding accessed outside of the view lifecycle" }
@@ -55,7 +55,7 @@ open class AudioFileFragment :
private var metadata: PlaybackItemMetadata? = null
private val file by lazy {
- requireNotNull(arguments.getSerializableArgument(ARGUMENT_FILE, PlaybackFile::class.java)) {
+ requireNotNull(arguments.getPlaybackFile(ARGUMENT_FILE)) {
"AudioFileFragment requires a $ARGUMENT_FILE argument"
}
}
@@ -118,15 +118,17 @@ open class AudioFileFragment :
}
private fun loadFileThumbnail(): Job = viewLifecycleOwner.lifecycleScope.launch {
- val thumbnailSize = resources.getDimensionPixelSize(R.dimen.player_album_cover_size)
- val thumbnail = thumbnailLoader.await(requireContext(), file, thumbnailSize, thumbnailSize) ?: return@launch
- binding.albumCover.setImageBitmap(thumbnail)
+ val context = context ?: return@launch
+ val thumbnailSize = context.resources.getDimensionPixelSize(R.dimen.player_album_cover_size)
+ val thumbnail = playerThumbnailLoader.await(file, thumbnailSize, thumbnailSize) ?: return@launch
+ val albumCover = _binding?.albumCover ?: return@launch
+ albumCover.setImageBitmap(thumbnail)
isFileThumbnailLoaded = true
}
private fun loadMetadataArtwork(metadata: PlaybackItemMetadata) {
val source = metadata.artworkData ?: metadata.artworkUri ?: return
- thumbnailLoader.load(binding.albumCover, source, file.id)
+ playerThumbnailLoader.load(binding.albumCover, source, file.id)
}
private fun PlaybackFile.getDetailsText(): String = listOfNotNull(
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt
index 2681412967a7..5ef66985a46c 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt
@@ -35,8 +35,8 @@ class AudioPlayerView(context: Context) : PlayerView(context) {
playerPager.setPadding(insets.left, 0, insets.right, 0)
playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom)
- windowWrapper.setupStatusBar(R.color.player_background_color, false)
- windowWrapper.setupNavigationBar(R.color.player_background_color, true)
+ windowWrapper.setupStatusBar(R.color.player_background_color)
+ windowWrapper.setupNavigationBar(R.color.player_background_color)
return WindowInsetsCompat.CONSUMED.toWindowInsets()
}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt
deleted file mode 100644
index 39957620b263..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.ui.control
-
-import android.os.Handler
-import android.os.Looper
-import android.view.View
-
-private const val TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS = 250L
-private const val SINGLE_CLICK_COUNT = 1
-
-class MultipleClickListener(private val onSingleClick: () -> Unit, private val onDoubleClick: () -> Unit) :
- View.OnClickListener {
-
- private val handler = Handler(Looper.getMainLooper())
- private var clicksCount: Int? = null
-
- override fun onClick(view: View?) {
- val pendingClicksCount = clicksCount
- if (pendingClicksCount != null) {
- clicksCount = pendingClicksCount + 1
- return
- }
-
- clicksCount = SINGLE_CLICK_COUNT
- handler.postDelayed({
- val count = clicksCount ?: SINGLE_CLICK_COUNT
- clicksCount = null
- if (count == SINGLE_CLICK_COUNT) {
- onSingleClick()
- } else {
- onDoubleClick()
- }
- }, TIME_WINDOW_FOR_CLICK_DETERMINATION_IN_MILLISECONDS)
- }
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt
index 86baf378b418..b80b01e3299b 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt
@@ -8,13 +8,16 @@
package com.nextcloud.client.player.ui.control
import android.content.Context
+import android.os.Build
import android.util.AttributeSet
import android.view.LayoutInflater
+import android.view.WindowInsets
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
+import androidx.core.view.updateLayoutParams
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.flowWithLifecycle
@@ -23,6 +26,7 @@ import com.nextcloud.client.player.model.state.PlaybackItemState
import com.nextcloud.client.player.model.state.PlaybackState
import com.nextcloud.client.player.model.state.PlayerState
import com.nextcloud.client.player.model.state.RepeatMode
+import com.nextcloud.client.player.ui.MediaNavigator
import com.owncloud.android.R
import com.owncloud.android.databinding.PlayerControlViewBinding
import dagger.android.HasAndroidInjector
@@ -69,6 +73,8 @@ class PlayerControlView @JvmOverloads constructor(
val binding = PlayerControlViewBinding.inflate(LayoutInflater.from(context), this, true)
+ var navigator: MediaNavigator? = null
+
init {
if (!isInEditMode) {
(context.applicationContext as HasAndroidInjector).androidInjector().inject(this)
@@ -90,6 +96,7 @@ class PlayerControlView @JvmOverloads constructor(
viewScope?.cancel()
viewScope = null
}
+ navigator = null
super.onDetachedFromWindow()
}
@@ -102,6 +109,19 @@ class PlayerControlView @JvmOverloads constructor(
playbackModel.removeListener(this)
}
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ // Clear insets to avoid covering ui
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val insets = rootWindowInsets.getInsets(WindowInsets.Type.systemBars())
+ binding.playerControlPanel.updateLayoutParams {
+ leftMargin = insets.left
+ bottomMargin = insets.bottom
+ rightMargin = insets.right
+ }
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ }
+
override fun onPlaybackUpdate(state: PlaybackState) {
render(state)
}
@@ -121,6 +141,9 @@ class PlayerControlView @JvmOverloads constructor(
}
binding.ivRepeat.setOnClickListener {
+ // Repeat and Random are mutually exclusive
+ playbackModel.setShuffle(false)
+
when (binding.ivRepeat.tag) {
TAG_CLICK_COMMAND_REPEAT -> playbackModel.setRepeatMode(RepeatMode.SINGLE)
TAG_CLICK_COMMAND_DO_NOT_REPEAT -> playbackModel.setRepeatMode(RepeatMode.ALL)
@@ -128,25 +151,15 @@ class PlayerControlView @JvmOverloads constructor(
}
binding.ivRandom.setOnClickListener {
+ // Repeat and Random are mutually exclusive
+ playbackModel.setRepeatMode(RepeatMode.OFF)
+
playbackModel.setShuffle(binding.ivRandom.tag == TAG_CLICK_COMMAND_SHUFFLE)
}
- binding.ivNext.setOnClickListener { playbackModel.playNext() }
-
- binding.ivPrevious.setOnClickListener(
- MultipleClickListener(
- onSingleClick = {
- playbackModel.state?.currentItemState?.let { state ->
- if (state.playerState == PlayerState.PAUSED || state.playerState == PlayerState.PLAYING) {
- playbackModel.seekToPosition(0L)
- } else {
- playbackModel.playPrevious()
- }
- }
- },
- onDoubleClick = { playbackModel.playPrevious() }
- )
- )
+ binding.ivNext.setOnClickListener { navigator?.showNext() }
+
+ binding.ivPrevious.setOnClickListener { navigator?.showPrevious() }
binding.progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
@@ -176,7 +189,7 @@ class PlayerControlView @JvmOverloads constructor(
renderRepeatButton(playbackState.repeatMode == RepeatMode.SINGLE)
renderShuffleButton(playbackState.shuffle)
renderPlayPauseButton(playbackState.currentItemState?.playerState == PlayerState.PLAYING)
- renderNextPreviousButtons(playbackState)
+ renderNextPreviousButtons()
renderProgressBar(playbackState.currentItemState)
}
@@ -216,9 +229,9 @@ class PlayerControlView @JvmOverloads constructor(
binding.ivPlayPause.tag = if (isPlaying) TAG_CLICK_COMMAND_PAUSE else TAG_CLICK_COMMAND_PLAY
}
- private fun renderNextPreviousButtons(playbackState: PlaybackState) {
- binding.ivNext.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.size > 1)
- binding.ivPrevious.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.isNotEmpty())
+ private fun renderNextPreviousButtons() {
+ binding.ivNext.isEnabled = navigator?.hasNext == true
+ binding.ivPrevious.isEnabled = navigator?.hasPrevious == true
}
private fun renderProgressBar(playbackItemState: PlaybackItemState?) {
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt
index 811bb23c6147..455a84e9f03b 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt
@@ -1,12 +1,14 @@
/*
* Nextcloud - Android Client
*
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
* SPDX-FileCopyrightText: 2025 STRATO GmbH.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.player.ui.pager
+import android.annotation.SuppressLint
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
@@ -14,49 +16,55 @@ import android.util.AttributeSet
import android.widget.LinearLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
-import androidx.viewpager.widget.ViewPager
-import androidx.viewpager.widget.ViewPager.OnPageChangeListener
+import androidx.lifecycle.Lifecycle
+import androidx.viewpager2.widget.ViewPager2
import com.nextcloud.client.player.model.file.PlaybackFile
-import com.nextcloud.client.player.util.rotate
+import com.nextcloud.client.player.ui.MediaNavigator
+import com.nextcloud.client.player.util.PlayerUtil.rotate
import com.owncloud.android.R
private const val NO_SHIFT = -1
+private const val NO_POSITION = -1
+private const val FIRST_ENTITY_POSITION = 1
+private const val OFFSCREEN_PAGE_LIMIT = 1
class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
- LinearLayout(context, attrs) {
+ LinearLayout(context, attrs),
+ MediaNavigator {
- private val viewPager: ViewPager
+ @SuppressLint("WrongConstant")
+ private val viewPager = ViewPager2(context).apply {
+ id = R.id.player_view_pager
+ offscreenPageLimit = OFFSCREEN_PAGE_LIMIT
+ }
private lateinit var adapter: PlayerPagerAdapter
private var shift = NO_SHIFT
private var restoredShift = NO_SHIFT
var onItemSelected: ((PlaybackFile) -> Unit)? = null
- private val onPageChangeListener = object : OnPageChangeListener {
- override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit
+ private val lastStubPosition get() = adapter.itemCount - 1
+
+ private val lastEntityPosition get() = adapter.itemCount - 2
+ private val onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
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
- }
+ if (isStubPosition(position)) return
onItemSelected?.invoke(adapter.getEntityForPosition(position))
}
- override fun onPageScrollStateChanged(state: Int) = Unit
+ override fun onPageScrollStateChanged(state: Int) {
+ if (state != ViewPager2.SCROLL_STATE_IDLE) return
+ wrapAroundStubPosition()
+ }
}
init {
- inflate(context, R.layout.player_pager, this)
- viewPager = findViewById(R.id.viewPager)
+ addView(viewPager, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}
- fun initialize(fragmentManager: FragmentManager, createFragment: (PlaybackFile) -> Fragment) {
- adapter = PlayerPagerAdapter(fragmentManager, createFragment)
+ fun initialize(fragmentManager: FragmentManager, lifecycle: Lifecycle, createFragment: (PlaybackFile) -> Fragment) {
+ adapter = PlayerPagerAdapter(fragmentManager, lifecycle, createFragment)
viewPager.adapter = adapter
}
@@ -72,6 +80,11 @@ class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSe
restoredShift = restoredState.shiftedPosition
}
+ fun release() {
+ viewPager.unregisterOnPageChangeCallback(onPageChangeCallback)
+ viewPager.adapter = null
+ }
+
fun getItems(): List = adapter.getEntities()
fun setItems(items: List) {
@@ -79,19 +92,43 @@ class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSe
notifyDataSetChangedWithoutCallingListener()
}
+ override val hasNext: Boolean get() = adapter.itemCount > 1
+
+ override val hasPrevious: Boolean get() = adapter.itemCount > 1
+
+ override fun showNext() {
+ viewPager.setCurrentItem(viewPager.currentItem + 1, true)
+ }
+
+ override fun showPrevious() {
+ viewPager.setCurrentItem(viewPager.currentItem - 1, true)
+ }
+
fun setCurrentItem(item: PlaybackFile) {
val position = adapter.getEntityIndex(item)
- if (position != -1 && viewPager.currentItem != position) {
- viewPager.removeOnPageChangeListener(onPageChangeListener)
- viewPager.setCurrentItem(position, true)
- viewPager.addOnPageChangeListener(onPageChangeListener)
+ if (position == NO_POSITION || viewPager.currentItem == position) return
+
+ viewPager.unregisterOnPageChangeCallback(onPageChangeCallback)
+ viewPager.setCurrentItem(position, viewPager.isLaidOut)
+ viewPager.registerOnPageChangeCallback(onPageChangeCallback)
+ }
+
+ private fun isStubPosition(position: Int): Boolean = position == 0 || position >= lastStubPosition
+
+ private fun wrapAroundStubPosition() {
+ if (!adapter.isPadded()) return
+
+ when {
+ viewPager.currentItem == 0 -> viewPager.setCurrentItem(lastEntityPosition, false)
+ viewPager.currentItem >= lastStubPosition -> viewPager.setCurrentItem(FIRST_ENTITY_POSITION, false)
}
}
+ @SuppressLint("NotifyDataSetChanged")
private fun notifyDataSetChangedWithoutCallingListener() {
- viewPager.removeOnPageChangeListener(onPageChangeListener)
+ viewPager.unregisterOnPageChangeCallback(onPageChangeCallback)
adapter.notifyDataSetChanged()
- viewPager.addOnPageChangeListener(onPageChangeListener)
+ viewPager.registerOnPageChangeCallback(onPageChangeCallback)
}
private fun shiftRestoredPosition(items: List): List {
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt
index 2e016d7deb99..cdac8a4f7624 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt
@@ -7,23 +7,28 @@
package com.nextcloud.client.player.ui.pager
+import android.annotation.SuppressLint
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.FragmentStatePagerAdapter
+import androidx.lifecycle.Lifecycle
+import androidx.viewpager2.adapter.FragmentStateAdapter
import com.nextcloud.client.player.model.file.PlaybackFile
/**
* Pads the item list with a copy of the last item at the front and a copy of the first item at the end, so that
* [PlayerPager] can wrap around and give the impression of an endless pager.
*/
-@Suppress("DEPRECATION")
-class PlayerPagerAdapter(fragmentManager: FragmentManager, private val createFragment: (PlaybackFile) -> Fragment) :
- FragmentStatePagerAdapter(fragmentManager) {
+class PlayerPagerAdapter(
+ fragmentManager: FragmentManager,
+ lifecycle: Lifecycle,
+ private val fragmentFactory: (PlaybackFile) -> Fragment
+) : FragmentStateAdapter(fragmentManager, lifecycle) {
private var paddedEntities = mutableListOf()
fun getEntities(): List = if (isPadded()) removeStubs(paddedEntities) else paddedEntities
+ @SuppressLint("NotifyDataSetChanged")
fun setEntities(entities: List) {
paddedEntities = if (entities.size > 1) addStubs(entities) else entities.toMutableList()
notifyDataSetChanged()
@@ -38,13 +43,18 @@ class PlayerPagerAdapter(fragmentManager: FragmentManager, private val createFra
fun getEntityForPosition(position: Int): PlaybackFile = paddedEntities[position]
- override fun getItem(position: Int): Fragment = createFragment(paddedEntities[position])
+ override fun getItemCount(): Int = paddedEntities.size
- override fun getCount(): Int = paddedEntities.size
+ override fun createFragment(position: Int): Fragment = fragmentFactory(paddedEntities[position])
- override fun getItemPosition(item: Any): Int = POSITION_NONE
+ override fun getItemId(position: Int): Long = itemIdAt(position)
- private fun isPadded(): Boolean = paddedEntities.size > 1
+ override fun containsItem(itemId: Long): Boolean = paddedEntities.indices.any { itemIdAt(it) == itemId }
+
+ private fun itemIdAt(position: Int): Long =
+ (position.toLong() shl Int.SIZE_BITS) or paddedEntities[position].id.hashCode().toUInt().toLong()
+
+ fun isPadded(): Boolean = paddedEntities.size > 1
private fun addStubs(sources: List): MutableList {
val result = sources.toMutableList()
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt
index 24d8dbe699b9..ee2860be96de 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt
@@ -11,17 +11,18 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.nextcloud.client.player.media3.PlaybackModel
-import com.nextcloud.client.player.model.ThumbnailLoader
+import com.nextcloud.client.player.model.PlayerThumbnailLoader
import com.nextcloud.client.player.model.file.PlaybackFile
import com.nextcloud.client.player.model.state.PlaybackState
import com.nextcloud.client.player.model.state.VideoSize
-import com.nextcloud.client.player.util.applyVideoSize
-import com.nextcloud.utils.extensions.getSerializableArgument
+import com.nextcloud.client.player.util.PlayerUtil.applyVideoSize
+import com.nextcloud.client.player.util.PlayerUtil.getPlaybackFile
+import com.nextcloud.client.player.util.PlayerUtil.ownsPlayback
+import com.nextcloud.client.player.util.PlayerUtil.putPlaybackFile
import com.owncloud.android.R
import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding
import dagger.android.support.AndroidSupportInjection
@@ -38,7 +39,7 @@ class VideoFileFragment :
private const val SURFACE_ALPHA_HIDDEN = 0f
fun createInstance(file: PlaybackFile) = VideoFileFragment().apply {
- arguments = bundleOf(ARGUMENT_FILE to file)
+ arguments = Bundle().apply { putPlaybackFile(ARGUMENT_FILE, file) }
}
}
@@ -46,15 +47,15 @@ class VideoFileFragment :
lateinit var playerModel: PlaybackModel
@Inject
- lateinit var thumbnailLoader: ThumbnailLoader
+ lateinit var playerThumbnailLoader: PlayerThumbnailLoader
private var _binding: PlayerVideoFileFragmentBinding? = null
private val binding get() = checkNotNull(_binding) { "Binding accessed outside of the view lifecycle" }
- private var previousVideoSize: VideoSize? = null
+ private var renderedVideoSize: VideoSize? = null
private val file by lazy {
- requireNotNull(arguments.getSerializableArgument(ARGUMENT_FILE, PlaybackFile::class.java)) {
+ requireNotNull(arguments.getPlaybackFile(ARGUMENT_FILE)) {
"VideoFileFragment requires a $ARGUMENT_FILE argument"
}
}
@@ -70,6 +71,9 @@ class VideoFileFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadFileThumbnail()
+ binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ render(playerModel.state)
+ }
}
override fun onStart() {
@@ -78,12 +82,18 @@ class VideoFileFragment :
playerModel.addListener(this)
}
+ override fun onResume() {
+ super.onResume()
+ render(playerModel.state)
+ }
+
override fun onStop() {
playerModel.removeListener(this)
super.onStop()
}
override fun onDestroyView() {
+ _binding?.let { playerModel.clearVideoSurfaceView(it.surfaceView) }
_binding = null
super.onDestroyView()
}
@@ -93,12 +103,14 @@ class VideoFileFragment :
}
private fun loadFileThumbnail() = viewLifecycleOwner.lifecycleScope.launch {
- val thumbnailSize = resources.getDimensionPixelSize(R.dimen.player_album_cover_size)
- val thumbnail = thumbnailLoader.await(requireContext(), file, thumbnailSize, thumbnailSize) ?: return@launch
- binding.thumbnail.setImageBitmap(thumbnail)
+ val context = context ?: return@launch
+ val thumbnailSize = context.resources.getDimensionPixelSize(R.dimen.player_album_cover_size)
+ val thumbnail = playerThumbnailLoader.await(file, thumbnailSize, thumbnailSize) ?: return@launch
+ _binding?.thumbnail?.setImageBitmap(thumbnail)
}
private fun render(state: PlaybackState?) {
+ val binding = _binding ?: return
val currentItemState = state?.currentItemState
if (currentItemState?.file == file) {
@@ -106,6 +118,7 @@ class VideoFileFragment :
return
}
+ renderedVideoSize = null
binding.surfaceView.isVisible = false
if (currentItemState == null) {
playerModel.setVideoSurfaceView(null)
@@ -113,13 +126,18 @@ class VideoFileFragment :
}
private fun showVideo(videoSize: VideoSize?) {
- playerModel.setVideoSurfaceView(binding.surfaceView)
+ if (ownsPlayback(binding.surfaceView)) {
+ playerModel.setVideoSurfaceView(binding.surfaceView)
+ }
+
+ val size = videoSize ?: renderedVideoSize
+ renderedVideoSize = size
+
binding.surfaceView.isVisible = true
- binding.surfaceView.alpha = if (videoSize == null) SURFACE_ALPHA_HIDDEN else SURFACE_ALPHA_VISIBLE
+ binding.surfaceView.alpha = if (size == null) SURFACE_ALPHA_HIDDEN else SURFACE_ALPHA_VISIBLE
- if (videoSize == null || previousVideoSize == videoSize) return
+ if (size == null) return
- previousVideoSize = videoSize
- binding.surfaceView.applyVideoSize(videoSize)
+ binding.surfaceView.applyVideoSize(size)
}
}
diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt
index af265087d9ec..652f2b204212 100644
--- a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt
+++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt
@@ -40,7 +40,9 @@ class VideoPlayerView(context: Context) : PlayerView(context) {
override fun onStart() {
super.onStart()
- showControls()
+ if (!activity.isInPictureInPictureMode) {
+ showControls()
+ }
}
override fun onStop() {
@@ -56,8 +58,8 @@ class VideoPlayerView(context: Context) : PlayerView(context) {
topBar.setPadding(insets.left, insets.top, insets.right, 0)
playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom)
- windowWrapper.setupStatusBar(R.color.player_video_toolbar_background_color, false)
- windowWrapper.setupNavigationBar(R.color.player_video_control_view_background_color, false)
+ windowWrapper.setupStatusBar(R.color.player_video_toolbar_background_color)
+ windowWrapper.setupNavigationBar(R.color.player_video_control_view_background_color)
return WindowInsetsCompat.CONSUMED.toWindowInsets()
}
@@ -90,6 +92,11 @@ class VideoPlayerView(context: Context) : PlayerView(context) {
private fun restartHideControlsTimer() {
hideControlsTimerJob?.cancel()
+
+ if (activity.isInPictureInPictureMode) {
+ return
+ }
+
hideControlsTimerJob = activity.lifecycleScope.launch {
delay(HIDE_CONTROLS_DELAY)
hideControls()
diff --git a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt
deleted file mode 100644
index b61117bf5987..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.util
-
-import android.content.ContentResolver
-import android.database.ContentObserver
-import android.net.Uri
-import android.os.Handler
-import android.os.Looper
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.callbackFlow
-
-fun ContentResolver.observeContentChanges(uri: Uri, notifyForDescendants: Boolean) = callbackFlow {
- val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
- override fun onChange(selfChange: Boolean) {
- trySend(selfChange)
- }
- }
- registerContentObserver(uri, notifyForDescendants, contentObserver)
- awaitClose { unregisterContentObserver(contentObserver) }
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/util/Context.kt b/app/src/main/java/com/nextcloud/client/player/util/Context.kt
deleted file mode 100644
index 7a7080565b95..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/util/Context.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.util
-
-import android.app.AppOpsManager
-import android.content.Context
-import android.content.pm.PackageManager
-import android.os.Build
-import android.os.Process
-
-fun Context.isPictureInPictureAllowed(): Boolean {
- if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
- val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
- appOpsManager?.let {
- val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- it.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName)
- } else {
- @Suppress("DEPRECATION")
- it.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName)
- }
- return mode == AppOpsManager.MODE_ALLOWED
- }
- }
- return false
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/util/List.kt b/app/src/main/java/com/nextcloud/client/player/util/List.kt
deleted file mode 100644
index 05c238c78a1d..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/util/List.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.util
-
-import java.util.Collections
-
-fun List.rotate(shift: Int): List {
- val copy = ArrayList(this)
- Collections.rotate(copy, shift)
- return copy
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt
new file mode 100644
index 000000000000..e8ff924c2aeb
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/player/util/PlayerUtil.kt
@@ -0,0 +1,285 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-FileCopyrightText: 2025 STRATO GmbH.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.player.util
+
+import android.app.AppOpsManager
+import android.content.ContentResolver
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.ContentObserver
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Bundle
+import android.util.LruCache
+import android.os.Handler
+import android.os.Looper
+import android.os.Process
+import android.view.SurfaceView
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.Player
+import com.nextcloud.client.player.model.file.PlaybackFile
+import com.nextcloud.client.player.model.state.PlaybackItemMetadata
+import com.nextcloud.client.player.model.state.PlaybackItemState
+import com.nextcloud.client.player.model.state.PlaybackState
+import com.nextcloud.client.player.model.state.PlayerState
+import com.nextcloud.client.player.model.state.RepeatMode
+import com.nextcloud.client.player.model.state.VideoSize
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.resources.shares.OCShare
+import com.owncloud.android.utils.MimeTypeUtil
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.serialization.json.Json
+import java.io.File
+import java.util.Collections
+
+@Suppress("TooManyFunctions")
+object PlayerUtil {
+
+ const val REMOTE_FILE_SCHEME = "remoteFile"
+
+ private const val PLAYBACK_FILE_KEY = "playback_file"
+ private const val NO_INDEX = -1
+ private const val UNKNOWN_CONTENT_LENGTH = -1L
+ private const val SECOND_IN_MILLISECONDS = 1000L
+ private const val PLAYBACK_FILE_CACHE_SIZE = 512
+
+ private val playbackJson = Json { ignoreUnknownKeys = true }
+
+ private val playbackFileCache = LruCache(PLAYBACK_FILE_CACHE_SIZE)
+
+ fun PlaybackFile.toMediaItem(): MediaItem = MediaItem
+ .Builder()
+ .setMediaId(id)
+ .setUri(uri)
+ .setMediaMetadata(MediaMetadata.Builder().setExtras(this).build())
+ .setMimeType(mimeType)
+ .build()
+
+ fun MediaMetadata.Builder.setExtras(playbackFile: PlaybackFile): MediaMetadata.Builder = setExtras(
+ Bundle().apply {
+ putPlaybackFile(PLAYBACK_FILE_KEY, playbackFile)
+ }
+ )
+
+ val MediaMetadata.playbackFile: PlaybackFile?
+ get() = extras.getPlaybackFile(PLAYBACK_FILE_KEY)
+
+ fun Player.indexOfFirst(satisfies: (MediaItem) -> Boolean): Int {
+ for (index in 0..) {
+ val oldCurrentMediaItemIndex = currentMediaItemIndex.takeIf { it >= 0 }
+
+ val newCurrentMediaItemIndex = currentMediaItem
+ ?.mediaId
+ ?.let { currentMediaId -> newMediaItems.indexOfFirst { it.mediaId == currentMediaId } }
+ ?.takeIf { it >= 0 }
+
+ if (oldCurrentMediaItemIndex == null || newCurrentMediaItemIndex == null) {
+ setMediaItems(newMediaItems)
+ return
+ }
+
+ replaceMediaItems(
+ oldCurrentMediaItemIndex + 1,
+ mediaItemCount,
+ newMediaItems.subList(newCurrentMediaItemIndex + 1, newMediaItems.size)
+ )
+ replaceMediaItems(0, oldCurrentMediaItemIndex, newMediaItems.subList(0, newCurrentMediaItemIndex))
+ }
+
+ fun Player.setRepeatMode(mode: RepeatMode) {
+ repeatMode = when (mode) {
+ RepeatMode.SINGLE -> Player.REPEAT_MODE_ONE
+ RepeatMode.ALL -> Player.REPEAT_MODE_ALL
+ RepeatMode.OFF -> Player.REPEAT_MODE_OFF
+ }
+ }
+
+ fun Player.toPlaybackState(currentFiles: List): PlaybackState = PlaybackState(
+ currentFiles = currentFiles,
+ currentItemState = getCurrentItemState(),
+ repeatMode = mapRepeatMode(),
+ shuffle = shuffleModeEnabled
+ )
+
+ fun Player.readMediaIds(): List = buildList {
+ for (i in 0 until mediaItemCount) {
+ add(getMediaItemAt(i).mediaId)
+ }
+ }
+
+ fun Player.readCurrentFiles(): List = buildList {
+ for (i in 0 until mediaItemCount) {
+ getMediaItemAt(i).mediaMetadata.playbackFile?.let(::add)
+ }
+ }
+
+ private fun Player.getCurrentItemState(): PlaybackItemState? {
+ val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null
+ return PlaybackItemState(
+ file = currentFile,
+ playerState = mapPlayerState(),
+ metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null,
+ videoSize = mapVideoSize(),
+ currentTimeInMilliseconds = currentPosition,
+ maxTimeInMilliseconds = duration
+ )
+ }
+
+ private fun Player.mapPlayerState(): PlayerState = when (playbackState) {
+ Player.STATE_IDLE -> PlayerState.IDLE
+ Player.STATE_ENDED -> PlayerState.COMPLETED
+ Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED
+ else -> PlayerState.NONE
+ }
+
+ private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata(
+ title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(),
+ artist = mediaMetadata.artist,
+ album = mediaMetadata.albumTitle,
+ genre = mediaMetadata.genre,
+ year = mediaMetadata.recordingYear,
+ description = mediaMetadata.description,
+ artworkData = mediaMetadata.artworkData,
+ artworkUri = mediaMetadata.artworkUri?.toString()
+ )
+
+ private fun Player.mapVideoSize(): VideoSize? = videoSize
+ .takeIf { it.width > 0 && it.height > 0 }
+ ?.let { VideoSize(width = it.width, height = it.height) }
+
+ private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) {
+ Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE
+ Player.REPEAT_MODE_ALL -> RepeatMode.ALL
+ else -> RepeatMode.OFF
+ }
+
+ fun OCFile.toPlaybackFile() = PlaybackFile(
+ id = localId.toString(),
+ uri = getPlaybackUri().toString(),
+ name = fileName,
+ mimeType = mimeType,
+ contentLength = fileLength,
+ lastModified = modificationTimestamp,
+ isFavorite = isFavorite
+ )
+
+ fun OCShare.toPlaybackFile() = PlaybackFile(
+ id = fileSource.toString(),
+ uri = getPlaybackUri().toString(),
+ name = path?.let { File(it).name } ?: "",
+ mimeType = getMimeType(),
+ contentLength = UNKNOWN_CONTENT_LENGTH,
+ lastModified = sharedDate * SECOND_IN_MILLISECONDS,
+ isFavorite = isFavorite
+ )
+
+ private fun OCShare.getMimeType(): String = mimetype
+ ?.takeIf { it.isNotEmpty() }
+ ?: path?.let { MimeTypeUtil.getMimeTypeFromPath(it) }
+ ?: ""
+
+ fun OCFile.getPlaybackUri(): Uri = getPlaybackUri(localId)
+
+ fun OCShare.getPlaybackUri(): Uri = getPlaybackUri(fileSource)
+
+ fun getPlaybackUri(fileId: Long): Uri = Uri.Builder()
+ .scheme(REMOTE_FILE_SCHEME)
+ .authority("")
+ .appendPath(fileId.toString())
+ .build()
+
+ fun Uri.getRemoteFileId(): Long? = scheme
+ ?.takeIf { it == REMOTE_FILE_SCHEME }
+ ?.let { pathSegments.firstOrNull()?.toLongOrNull() }
+
+ fun Bundle.putPlaybackFile(key: String, playbackFile: PlaybackFile) {
+ putString(key, playbackJson.encodeToString(playbackFile))
+ }
+
+ fun Bundle?.getPlaybackFile(key: String): PlaybackFile? {
+ val encoded = this?.getString(key) ?: return null
+
+ return playbackFileCache[encoded]
+ ?: runCatching { playbackJson.decodeFromString(encoded) }
+ .getOrNull()
+ ?.also { playbackFileCache.put(encoded, it) }
+ }
+
+ fun ContentResolver.observeContentChanges(uri: Uri, notifyForDescendants: Boolean) = callbackFlow {
+ val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
+ override fun onChange(selfChange: Boolean) {
+ trySend(selfChange)
+ }
+ }
+ registerContentObserver(uri, notifyForDescendants, contentObserver)
+ awaitClose { unregisterContentObserver(contentObserver) }
+ }
+
+ fun Context.isPictureInPictureAllowed(): Boolean {
+ val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
+ if (appOpsManager == null || !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
+ return false
+ }
+
+ val mode = appOpsManager.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName)
+ return mode == AppOpsManager.MODE_ALLOWED
+ }
+
+ fun Fragment.ownsPlayback(surfaceView: SurfaceView): Boolean = isResumed || surfaceView.getGlobalVisibleRect(Rect())
+
+ fun List.rotate(shift: Int): List {
+ val copy = ArrayList(this)
+ Collections.rotate(copy, shift)
+ return copy
+ }
+
+ /**
+ * Letterboxes the surface inside its container so the video keeps its aspect ratio.
+ */
+ fun SurfaceView.applyVideoSize(videoSize: VideoSize) {
+ val container = parent as? View
+ val containerWidth = container?.width ?: 0
+ val containerHeight = container?.height ?: 0
+ if (containerWidth <= 0 || containerHeight <= 0) return
+
+ val containerProportion = containerWidth.toFloat() / containerHeight.toFloat()
+ val videoProportion = videoSize.width.toFloat() / videoSize.height.toFloat()
+
+ val targetWidth: Int
+ val targetHeight: Int
+ if (containerProportion < videoProportion) {
+ targetWidth = ViewGroup.LayoutParams.MATCH_PARENT
+ targetHeight = (containerWidth.toFloat() / videoProportion).toInt()
+ } else {
+ targetWidth = (videoProportion * containerHeight.toFloat()).toInt()
+ targetHeight = ViewGroup.LayoutParams.MATCH_PARENT
+ }
+
+ if (layoutParams.width == targetWidth && layoutParams.height == targetHeight) return
+
+ layoutParams = layoutParams.apply {
+ width = targetWidth
+ height = targetHeight
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt
deleted file mode 100644
index 9681bb72d0b4..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2025 STRATO GmbH.
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.util
-
-import android.content.Context
-import android.os.Build
-import android.util.DisplayMetrics
-import android.view.WindowManager
-
-fun Context.getDisplayWidth(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- windowManager.currentWindowMetrics.bounds.width()
-} else {
- getDisplayMetrics().widthPixels
-}
-
-fun Context.getDisplayHeight(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- windowManager.currentWindowMetrics.bounds.height()
-} else {
- getDisplayMetrics().heightPixels
-}
-
-private val Context.windowManager: WindowManager
- get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager
-
-@Suppress("DEPRECATION")
-private fun Context.getDisplayMetrics(): DisplayMetrics {
- val displayMetrics = DisplayMetrics()
- windowManager.defaultDisplay.getRealMetrics(displayMetrics)
- return displayMetrics
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt b/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt
deleted file mode 100644
index dcbfd3555790..000000000000
--- a/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.player.util
-
-import android.view.SurfaceView
-import android.view.ViewGroup
-import com.nextcloud.client.player.model.state.VideoSize
-
-/**
- * Letterboxes the surface inside its container so the video keeps its aspect ratio.
- */
-fun SurfaceView.applyVideoSize(videoSize: VideoSize) {
- val screenWidth = context.getDisplayWidth()
- val screenHeight = context.getDisplayHeight()
- val screenProportion = screenWidth.toFloat() / screenHeight.toFloat()
- val videoProportion = videoSize.width.toFloat() / videoSize.height.toFloat()
-
- layoutParams = layoutParams.apply {
- if (screenProportion < videoProportion) {
- width = ViewGroup.LayoutParams.MATCH_PARENT
- height = (screenWidth.toFloat() / videoProportion).toInt()
- } else {
- width = (videoProportion * screenHeight.toFloat()).toInt()
- height = ViewGroup.LayoutParams.MATCH_PARENT
- }
- }
-}
diff --git a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt
index 5a8fa141836d..0407bd97974f 100644
--- a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt
+++ b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt
@@ -7,15 +7,23 @@
package com.nextcloud.client.player.util
-import android.os.Build
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
import android.view.Window
+import android.widget.FrameLayout
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
+import androidx.annotation.IdRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
+import androidx.core.graphics.Insets
+import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.updateLayoutParams
+import com.owncloud.android.R
private const val LUMINANCE_THRESHOLD = 0.5
@@ -32,21 +40,40 @@ class WindowWrapper(private val window: Window) {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
}
- fun setupStatusBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) {
+ fun setupStatusBar(@ColorRes backgroundColorRes: Int) {
val backgroundColor = ContextCompat.getColor(context, backgroundColorRes)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- window.setStatusBarContrastEnforced(contrastEnforced)
- }
insetsController.isAppearanceLightStatusBars = isLightColor(backgroundColor)
+ drawSystemBarBackground(
+ R.id.player_status_bar_background,
+ Gravity.TOP,
+ backgroundColor,
+ systemBarInsets(WindowInsetsCompat.Type.statusBars()).top
+ )
}
- fun setupNavigationBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) {
+ fun setupNavigationBar(@ColorRes backgroundColorRes: Int) {
val backgroundColor = ContextCompat.getColor(context, backgroundColorRes)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- window.setNavigationBarContrastEnforced(contrastEnforced)
- }
- window.navigationBarColor = backgroundColor
insetsController.isAppearanceLightNavigationBars = isLightColor(backgroundColor)
+ drawSystemBarBackground(
+ R.id.player_navigation_bar_background,
+ Gravity.BOTTOM,
+ backgroundColor,
+ systemBarInsets(WindowInsetsCompat.Type.navigationBars()).bottom
+ )
+ }
+
+ private fun systemBarInsets(typeMask: Int): Insets =
+ ViewCompat.getRootWindowInsets(window.decorView)?.getInsets(typeMask) ?: Insets.NONE
+
+ private fun drawSystemBarBackground(@IdRes id: Int, gravity: Int, @ColorInt color: Int, height: Int) {
+ val content = window.findViewById(android.R.id.content) ?: return
+ val background = content.findViewById(id) ?: View(context).apply {
+ this.id = id
+ content.addView(this, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height, gravity))
+ }
+
+ background.setBackgroundColor(color)
+ background.updateLayoutParams { this.height = height }
}
private fun isLightColor(@ColorInt color: Int): Boolean = ColorUtils.calculateLuminance(color) > LUMINANCE_THRESHOLD
diff --git a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt
index d16ea8eef159..d7bcfb1d40e9 100644
--- a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt
+++ b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt
@@ -7,6 +7,7 @@
package com.nextcloud.utils
import android.graphics.Color
+import android.util.LruCache
import android.graphics.drawable.BitmapDrawable
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
@@ -23,31 +24,31 @@ import com.owncloud.android.utils.MimeTypeUtil
@Suppress("TooGenericExceptionCaught", "ReturnCount")
object OCFileUtils {
private const val TAG = "OCFileUtils"
+ private const val IMAGE_SIZE_CACHE_ENTRIES = 2048
+
+ private val imageSizes = LruCache>(IMAGE_SIZE_CACHE_ENTRIES)
fun getImageSize(ocFile: OCFile, defaultThumbnailSize: Float): Pair {
val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1)
val fallbackPair = fallback to fallback
- try {
- Log_OC.d(TAG, "Getting image size for: ${ocFile.fileName}")
+ imageSizes.get(ocFile.fileId)?.let { return it }
+ try {
// Server-provided
ocFile.imageDimension?.let { dim ->
val w = dim.width.toInt().coerceAtLeast(1)
val h = dim.height.toInt().coerceAtLeast(1)
- Log_OC.d(TAG, "Using server-provided imageDimension: $w x $h")
- return w to h
+ return (w to h).also { imageSizes.put(ocFile.fileId, it) }
}
// Local file
val path = ocFile.storagePath
if (!path.isNullOrEmpty() && ocFile.exists()) {
- path.getExifSize()?.let { return it }
- path.getBitmapSize()?.let { return it }
+ path.getExifSize()?.let { return it.also { size -> imageSizes.put(ocFile.fileId, size) } }
+ path.getBitmapSize()?.let { return it.also { size -> imageSizes.put(ocFile.fileId, size) } }
}
- // 3 Fallback
- Log_OC.d(TAG, "Fallback to default size: $fallback x $fallback")
return fallbackPair
} catch (e: Exception) {
Log_OC.e(TAG, "Error getting image size for ${ocFile.fileName}", e)
diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt
index f1a64c7b18c6..138af0d0060c 100644
--- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt
+++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt
@@ -25,7 +25,12 @@ fun OCFile?.logFileSize(tag: String) {
Log_OC.d(tag, "onSaveInstanceState: $size, raw byte $rawByte")
}
-fun ServerFileInterface.getThumbnailKeys(): List = listOf(getBigThumbnailKey(), getSmallThumbnailKey())
+fun ServerFileInterface.getThumbnailKeys(): List = listOf(
+ getBigThumbnailKey(),
+ getSmallThumbnailKey(),
+ videoOverlayKey(getBigThumbnailKey()),
+ videoOverlayKey(getSmallThumbnailKey())
+)
fun ServerFileInterface.getBigThumbnail(): Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(getBigThumbnailKey())
@@ -36,6 +41,8 @@ fun ServerFileInterface.getSmallThumbnail(): Bitmap? =
fun ServerFileInterface.getSmallThumbnailKey(): String = ThumbnailsCacheManager.PREFIX_THUMBNAIL + remoteId
+fun videoOverlayKey(thumbnailKey: String): String = ThumbnailsCacheManager.PREFIX_VIDEO_OVERLAY + thumbnailKey
+
fun File?.getSmallThumbnail(): Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(getSmallThumbnailKey())
fun File?.getSmallThumbnailKey(): String = ThumbnailsCacheManager.PREFIX_THUMBNAIL + hashCode()
diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt
index ae8b84b1efdc..464c8805ecfc 100644
--- a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt
+++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt
@@ -60,7 +60,7 @@ fun List.toGalleryItems(columns: Int, defaultSize: Int): List
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.utils.thumbnail
+
+import android.graphics.Bitmap
+import android.util.LruCache
+
+private const val BYTES_PER_KILOBYTE = 1024
+private const val HEAP_FRACTION = 8
+
+object ThumbnailMemoryCache {
+
+ private val cache = object : LruCache(maxSizeInKilobytes()) {
+ override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount / BYTES_PER_KILOBYTE
+ }
+
+ fun get(key: String): Bitmap? = cache.get(key)?.takeUnless { it.isRecycled }
+
+ fun put(key: String, bitmap: Bitmap) {
+ if (bitmap.isRecycled) {
+ return
+ }
+
+ cache.put(key, bitmap)
+ }
+
+ fun remove(key: String) {
+ cache.remove(key)
+ }
+
+ fun clear() {
+ cache.evictAll()
+ }
+
+ private fun maxSizeInKilobytes(): Int =
+ (Runtime.getRuntime().maxMemory() / BYTES_PER_KILOBYTE / HEAP_FRACTION).toInt()
+}
diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
index aa3867406938..ccad44f05d14 100644
--- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
+++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
@@ -524,12 +524,14 @@ public List getFolderImages(OCFile folder, boolean onlyOnDevice) {
public List getFolderImagesAndVideos(OCFile folder, boolean onlyOnDevice) {
List mediaList = new ArrayList<>();
+ HashSet mediaFileNames = new HashSet<>();
if (folder != null) {
List folderContent = getFolderContent(folder, onlyOnDevice);
for (OCFile ocFile : folderContent) {
- if (MimeTypeUtil.isImageOrVideo(ocFile)) {
+ if (MimeTypeUtil.isImageOrVideo(ocFile) && !mediaFileNames.contains(ocFile.getFileName())) {
+ mediaFileNames.add(ocFile.getFileName());
mediaList.add(ocFile);
}
}
diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java
index 326817a9a4ac..1328dd5f7d91 100644
--- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java
+++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java
@@ -39,6 +39,7 @@
import com.nextcloud.client.account.User;
import com.nextcloud.client.network.ConnectivityService;
import com.nextcloud.utils.BitmapExtensionsKt;
+import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache;
import com.nextcloud.utils.extensions.FileExtensionsKt;
import com.nextcloud.utils.extensions.OCFileExtensionsKt;
import com.nextcloud.utils.extensions.OwnCloudClientExtensionsKt;
@@ -98,6 +99,9 @@ public final class ThumbnailsCacheManager {
/** Cache key prefix {@code }; square, sized from {@link #getThumbnailDimension()}. */
public static final String PREFIX_THUMBNAIL = "t";
+ /** Memory-only cache key prefix {@code } for thumbnails with the video play overlay. */
+ public static final String PREFIX_VIDEO_OVERLAY = "o";
+
private static final String TAG = ThumbnailsCacheManager.class.getSimpleName();
private static final String PNG_MIMETYPE = "image/png";
private static final String CACHE_FOLDER = "thumbnailCache";
@@ -223,12 +227,18 @@ public static void removeFromCache(@Nullable OCFile file) {
return;
}
+ List keys = FileExtensionsKt.getThumbnailKeys(file);
+
+ for (String key : keys) {
+ ThumbnailMemoryCache.INSTANCE.remove(key);
+ }
+
synchronized (mThumbnailsDiskCacheLock) {
if (mThumbnailCache == null) {
return;
}
- for (String key: FileExtensionsKt.getThumbnailKeys(file)) {
+ for (String key : keys) {
mThumbnailCache.removeKey(key);
}
}
@@ -265,6 +275,7 @@ public static void addBitmapToCache(String key, Bitmap bitmap) {
}
mThumbnailCache.put(key, bitmap);
+ ThumbnailMemoryCache.INSTANCE.put(key, bitmap);
}
}
@@ -290,6 +301,11 @@ public static Bitmap getScaledBitmapFromDiskCache(String key, int width, int hei
}
public static Bitmap getBitmapFromDiskCache(String key) {
+ Bitmap fromMemory = ThumbnailMemoryCache.INSTANCE.get(key);
+ if (fromMemory != null) {
+ return fromMemory;
+ }
+
synchronized (mThumbnailsDiskCacheLock) {
// Wait while disk cache is started from background thread
while (mThumbnailCacheStarting) {
@@ -300,7 +316,11 @@ public static Bitmap getBitmapFromDiskCache(String key) {
}
}
if (mThumbnailCache != null) {
- return mThumbnailCache.getBitmap(key);
+ Bitmap fromDisk = mThumbnailCache.getBitmap(key);
+ if (fromDisk != null) {
+ ThumbnailMemoryCache.INSTANCE.put(key, fromDisk);
+ }
+ return fromDisk;
}
}
return null;
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt
index 8e5384b4a773..e48cdc9f8912 100644
--- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt
+++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt
@@ -72,6 +72,7 @@ import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster
import com.nextcloud.client.jobs.upload.FileUploadHelper
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.network.ClientFactory.CreationException
+import com.nextcloud.client.player.model.file.toPlaybackCollection
import com.nextcloud.client.player.ui.PlayerLauncher
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.client.utils.IntentUtil
@@ -79,6 +80,7 @@ import com.nextcloud.model.OCUploadLocalPathData
import com.nextcloud.model.WorkerState.OfflineOperationsCompleted
import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias
import com.nextcloud.utils.extensions.getParcelableArgument
+import com.nextcloud.utils.extensions.getSerializableArgument
import com.nextcloud.utils.extensions.isActive
import com.nextcloud.utils.extensions.isDialogFragmentReady
import com.nextcloud.utils.extensions.lastFragment
@@ -135,6 +137,7 @@ import com.owncloud.android.ui.fragment.EmptyListState
import com.owncloud.android.ui.fragment.FileDetailFragment
import com.owncloud.android.ui.fragment.FileFragment
import com.owncloud.android.ui.fragment.GalleryFragment
+import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState
import com.owncloud.android.ui.fragment.GroupfolderListFragment
import com.owncloud.android.ui.fragment.OCFileListFragment
import com.owncloud.android.ui.fragment.SearchType
@@ -2056,6 +2059,9 @@ class FileDisplayActivity :
}
}
+ fun canPreviewInMediaPager(file: OCFile?): Boolean =
+ PreviewImageFragment.canBePreviewed(file) || (file != null && MimeTypeUtil.isVideo(file))
+
fun previewImageWithSearchContext(file: OCFile, searchFragment: Boolean, currentSearchType: SearchType?) {
val type = if (searchFragment) {
when (currentSearchType) {
@@ -2067,7 +2073,14 @@ class FileDisplayActivity :
null
}
- startImagePreview(file, file.isDown, type)
+ val mediaState = if (type == VirtualFolderType.GALLERY) {
+ (listOfFilesFragment as? GalleryFragment)?.currentMediaState
+ } else {
+ null
+ }
+
+ val showPreview = file.isDown || MimeTypeUtil.isVideo(file)
+ startImagePreview(file, showPreview, type, mediaState)
}
fun previewFile(file: OCFile, setFabVisible: CompletionCallback?) {
@@ -2695,7 +2708,12 @@ class FileDisplayActivity :
}
}
- fun startImagePreview(file: OCFile, showPreview: Boolean, type: VirtualFolderType? = null) {
+ fun startImagePreview(
+ file: OCFile,
+ showPreview: Boolean,
+ type: VirtualFolderType? = null,
+ mediaState: MediaState? = null
+ ) {
if (user.isEmpty) {
Log_OC.e(TAG, "cannot start image preview")
return
@@ -2706,6 +2724,7 @@ class FileDisplayActivity :
putExtra(EXTRA_LIVE_PHOTO_FILE, file.livePhotoVideo)
putExtra(EXTRA_USER, user.get())
type?.let { putExtra(PreviewImageActivity.EXTRA_VIRTUAL_TYPE, it) }
+ mediaState?.let { putExtra(PreviewImageActivity.EXTRA_MEDIA_STATE, it) }
}
if (showPreview) {
@@ -2742,8 +2761,8 @@ class FileDisplayActivity :
}
private fun startMediaActivity(file: OCFile) {
- val searchType = listOfFilesFragment?.currentSearchType
- playerLauncher.launch(this, file, searchType)
+ val collection = listOfFilesFragment?.currentSearchType.toPlaybackCollection()
+ playerLauncher.launch(this, file, collection)
}
fun configureToolbarForPreview(file: OCFile?) {
@@ -2947,7 +2966,8 @@ class FileDisplayActivity :
startImagePreview(
file,
true,
- virtualType
+ virtualType,
+ bundle.getSerializableArgument(PreviewImageActivity.EXTRA_MEDIA_STATE, MediaState::class.java)
)
} else {
startImagePreview(file, true)
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt
index 999ef89063ef..e6f831ca2d6a 100644
--- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt
@@ -19,6 +19,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
+import androidx.recyclerview.widget.RecyclerView
import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
import com.nextcloud.client.account.User
@@ -318,6 +319,12 @@ class GalleryAdapter(
}
}
+ override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ super.onDetachedFromRecyclerView(recyclerView)
+ setLayoutManager(null)
+ cleanup()
+ }
+
override fun onBindFooterViewHolder(holder: SectionedViewHolder?, section: Int) = Unit
override fun swapDirectory(
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
index dd8dd77020fd..961bbfacfeb9 100644
--- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
@@ -8,6 +8,7 @@
package com.owncloud.android.ui.adapter
import android.content.Context
+import android.graphics.Bitmap
import android.view.View
import android.widget.ImageView
import androidx.core.content.ContextCompat
@@ -19,14 +20,16 @@ import com.nextcloud.client.jobs.gallery.GalleryImageGenerationJob
import com.nextcloud.client.jobs.gallery.GalleryImageGenerationListener
import com.nextcloud.client.jobs.upload.FileUploadHelper
import com.nextcloud.utils.OCFileUtils
-import com.nextcloud.utils.extensions.getBigThumbnail
-import com.nextcloud.utils.extensions.getSmallThumbnail
+import com.nextcloud.utils.extensions.getBigThumbnailKey
+import com.nextcloud.utils.extensions.getSmallThumbnailKey
import com.nextcloud.utils.extensions.makeRounded
import com.nextcloud.utils.extensions.setVisibleIf
import com.nextcloud.utils.extensions.stopShimmer
import com.nextcloud.utils.mdm.MDMConfig
+import com.nextcloud.utils.extensions.videoOverlayKey
import com.nextcloud.utils.thumbnail.ThumbnailArguments
import com.nextcloud.utils.thumbnail.ThumbnailGenerator
+import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
@@ -45,7 +48,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
@@ -109,24 +112,20 @@ class OCFileListDelegate(
galleryRowHolder: GalleryRowHolder,
imageDimension: Pair
) {
- // Cancel previous job for this ImageView
GalleryImageGenerationJob.cancelPreviousJob(imageView)
imageView.tag = file.fileId
- // set placeholder before async job
- val cachedBitmap = file.getBigThumbnail() ?: file.getSmallThumbnail()
- if (cachedBitmap != null) {
- val overlay = if (MimeTypeUtil.isVideo(file)) {
- ThumbnailsCacheManager.addVideoOverlay(cachedBitmap, context)
- } else {
- cachedBitmap
- }
- imageView.setImageBitmap(overlay)
- } else {
- imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file, imageDimension))
+ val displayable = file.takeUnless { it.isUpdateThumbnailNeeded }?.displayableThumbnailFromMemory()
+ if (displayable != null) {
+ imageView.setImageBitmap(displayable)
+ imageView.stopShimmer(shimmer)
+ bindGalleryRowListeners(imageView, file, galleryRowHolder)
+ return
}
+ imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file, imageDimension))
+
val job = ioScope.launch {
try {
galleryImageGenerationJob.run(
@@ -164,6 +163,32 @@ class OCFileListDelegate(
GalleryImageGenerationJob.storeJob(job, imageView)
+ bindGalleryRowListeners(imageView, file, galleryRowHolder)
+ }
+
+ private fun OCFile.displayableThumbnailFromMemory(): Bitmap? {
+ val thumbnailKey = listOf(getBigThumbnailKey(), getSmallThumbnailKey())
+ .firstOrNull { ThumbnailMemoryCache.get(it) != null }
+ ?: return null
+ val thumbnail = ThumbnailMemoryCache.get(thumbnailKey)
+
+ return when {
+ thumbnail == null -> null
+ MimeTypeUtil.isVideo(this) -> withVideoOverlay(thumbnailKey, thumbnail)
+ else -> thumbnail
+ }
+ }
+
+ private fun withVideoOverlay(thumbnailKey: String, thumbnail: Bitmap): Bitmap {
+ val overlayKey = videoOverlayKey(thumbnailKey)
+
+ return ThumbnailMemoryCache.get(overlayKey)
+ ?: ThumbnailsCacheManager.addVideoOverlay(thumbnail, context).also {
+ ThumbnailMemoryCache.put(overlayKey, it)
+ }
+ }
+
+ private fun bindGalleryRowListeners(imageView: ImageView, file: OCFile, galleryRowHolder: GalleryRowHolder) {
imageView.setOnClickListener {
if (context is AlbumsPickerActivity) {
ocFileListFragmentInterface.onLongItemClicked(
@@ -426,7 +451,7 @@ class OCFileListDelegate(
@Suppress("TooGenericExceptionCaught")
fun cleanup() {
- ioScope.cancel()
+ ioScope.coroutineContext.cancelChildren()
try {
GalleryImageGenerationJob.cancelAllActiveJobs()
diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt
index 44217195bf06..3febf194e283 100644
--- a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt
@@ -354,6 +354,20 @@ open class ExtendedListFragment :
override fun onDestroyView() {
super.onDestroyView()
binding = null
+ mRecyclerView?.setOnTouchListener(null)
+ mRecyclerView?.adapter = null
+ mRecyclerView?.layoutManager = null
+ mRecyclerView = null
+ mRefreshListLayout?.setOnRefreshListener(null)
+ mRefreshListLayout = null
+ mSortButton = null
+ mSwitchGridViewButton = null
+ mEmptyListContainer = null
+ mEmptyListMessage = null
+ mEmptyListHeadline = null
+ mEmptyListIcon = null
+ closeButton = null
+ mScaleGestureDetector = null
}
private inner class ScaleListener : SimpleOnScaleGestureListener() {
diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt
index c538c7d1ea9e..6d5404fd0dcc 100644
--- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt
@@ -144,7 +144,7 @@ class GalleryFragment :
LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(refreshSearchEventReceiver)
- adapter?.cleanup()
+ adapter = null
super.onDestroyView()
}
@@ -228,6 +228,9 @@ class GalleryFragment :
override fun getCommonAdapter(): CommonOCFileListAdapterInterface? = adapter
+ val currentMediaState: MediaState?
+ get() = bottomSheet?.currMediaState
+
override fun onResume() {
super.onResume()
diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java
index 5c9e1caf343d..b473910fd773 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
@@ -238,7 +238,7 @@ protected enum MenuItemAddRemove {
protected MenuItemAddRemove menuItemAddRemoveValue = MenuItemAddRemove.ADD_GRID_AND_SORT_WITH_SEARCH;
- private List