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 mOriginalMenuItems = new ArrayList<>(); + private final List mOriginalMenuItems = new ArrayList<>(); private static OCFileDepth fileDepth = OCFileDepth.Root; @@ -308,14 +308,14 @@ public void onAttach(@NonNull Context context) { try { mContainerActivity = (FileFragment.ContainerActivity) context; } catch (ClassCastException e) { - throw new IllegalArgumentException(context.toString() + " must implement " + + throw new IllegalArgumentException(context + " must implement " + FileFragment.ContainerActivity.class.getSimpleName(), e); } try { setOnRefreshListener((OnEnforceableRefreshListener) context); } catch (ClassCastException e) { - throw new IllegalArgumentException(context.toString() + " must implement " + + throw new IllegalArgumentException(context + " must implement " + OnEnforceableRefreshListener.class.getSimpleName(), e); } } @@ -574,7 +574,7 @@ public void uploadFromApp() { action = action.setType("*/*").addCategory(Intent.CATEGORY_OPENABLE); action.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - getActivity().startActivityForResult( + requireActivity().startActivityForResult( Intent.createChooser(action, getString(R.string.upload_chooser_title)), FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS ); @@ -764,8 +764,9 @@ public void onHeaderClicked() { } @Override - public void showTemplate(@NonNull Creator creator, @NonNull String headline) { - ChooseTemplateDialogFragment.newInstance(mFile, creator, headline).show(requireActivity().getSupportFragmentManager(), + public void showTemplate(Creator creator, String headline) { + ChooseTemplateDialogFragment.newInstance(mFile, creator, headline).show(requireActivity() + .getSupportFragmentManager(), DIALOG_CREATE_DOCUMENT); } @@ -907,7 +908,9 @@ public boolean onPrepareActionMode(ActionMode mode, Menu menu) { final int checkedCount = checkedFiles.size(); if (mActiveActionMode != null) { - String title = getResources().getQuantityString(R.plurals.items_selected_count, checkedCount, checkedCount); + String title = getResources().getQuantityString(R.plurals.items_selected_count, + checkedCount, + checkedCount); mActiveActionMode.setTitle(title); } @@ -981,7 +984,7 @@ protected void setChoiceModeAsMultipleModal(Bundle savedInstanceState) { if (savedInstanceState != null) { mMultiChoiceModeListener.loadStateFrom(savedInstanceState); } - ((FileActivity) getActivity()).addDrawerListener(mMultiChoiceModeListener); + ((FileActivity) requireActivity()).addDrawerListener(mMultiChoiceModeListener); } /** @@ -1129,7 +1132,9 @@ public boolean onLongItemClicked(OCFile file) { private void folderOnItemClick(OCFile file, int position) { if (requireActivity() instanceof FolderPickerActivity fpa) { - String filenameErrorMessage = FileNameValidator.INSTANCE.checkFileName(file.getFileName(), getCapabilities(), requireContext()); + String filenameErrorMessage = FileNameValidator.INSTANCE.checkFileName(file.getFileName(), + getCapabilities(), + requireContext()); if (filenameErrorMessage != null) { DisplayUtils.showSnackMessage(fpa, filenameErrorMessage); return; @@ -1163,7 +1168,7 @@ private void fileOnItemClick(OCFile file) { return; } - if (PreviewImageFragment.canBePreviewed(file) && mContainerActivity instanceof FileDisplayActivity fda) { + if (mContainerActivity instanceof FileDisplayActivity fda && fda.canPreviewInMediaPager(file)) { fda.previewImageWithSearchContext(file, searchFragment, currentSearchType); } else if (file.isDown() && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewFile(file, this::setFabVisible); @@ -1309,7 +1314,7 @@ public boolean onFileActionChosen(@IdRes final int itemId, Set checkedFi return true; } else if (itemId == R.id.action_rename_file) { RenameFileDialogFragment dialog = RenameFileDialogFragment.newInstance(singleFile, mFile); - dialog.show(getFragmentManager(), FileDetailFragment.FTAG_RENAME_FILE); + dialog.show(requireFragmentManager(), FileDetailFragment.FTAG_RENAME_FILE); return true; } else if (itemId == R.id.action_see_details) { if (mActiveActionMode != null) { @@ -1348,7 +1353,7 @@ public boolean onFileActionChosen(@IdRes final int itemId, Set checkedFi if (itemId == R.id.action_remove_file) { RemoveFilesDialogFragment dialog = RemoveFilesDialogFragment.newInstance(new ArrayList<>(checkedFiles), mActiveActionMode); - dialog.show(getFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION); + dialog.show(requireFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION); return true; } else if (itemId == R.id.action_download_file || itemId == R.id.action_sync_file) { syncAndCheckFiles(checkedFiles); @@ -1442,7 +1447,7 @@ private void pickFolderForMoveOrCopy(final Set checkedFiles) { action.putExtra(FolderPickerActivity.EXTRA_FOLDER, getCurrentFile()); action.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); // No animation since we stay in the same folder action.putExtra(FolderPickerActivity.EXTRA_ACTION, extraAction); - getActivity().startActivityForResult(action, requestCode); + requireActivity().startActivityForResult(action, requestCode); } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt index 604b1c84c8ac..4c4d1cbd8e8f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -88,7 +88,6 @@ import com.owncloud.android.ui.fragment.helper.ColumnCount import com.owncloud.android.ui.helpers.UriUploader import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface import com.owncloud.android.ui.preview.PreviewImageFragment -import com.owncloud.android.ui.preview.PreviewMediaActivity.Companion.canBePreviewed import com.owncloud.android.utils.ClipboardUtil import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.ErrorMessageAdapter @@ -232,6 +231,7 @@ class AlbumItemsFragment : override fun onDestroyView() { lastMediaItemPosition = 0 addMediaFab = null + binding.listRoot.adapter = null super.onDestroyView() } @@ -319,7 +319,7 @@ class AlbumItemsFragment : setUpSelectionMode() } - binding.listRoot.adapter = adapter + if (binding.listRoot.adapter == null) binding.listRoot.adapter = adapter lastMediaItemPosition?.let { binding.listRoot.layoutManager?.scrollToPosition(it) } } @@ -561,13 +561,16 @@ class AlbumItemsFragment : PreviewImageFragment.canBePreviewed(file) -> activity.startImagePreview(file, !file.isDown, VirtualFolderType.ALBUM) - file.isDown && canBePreviewed(file) -> - activity.startMediaPreview(file, 0, true, true, false, true) + MimeTypeUtil.isVideo(file) -> + activity.startImagePreview(file, true, VirtualFolderType.ALBUM) + + file.isDown && activity.canMediaPreviewed(file) -> + activity.startMediaPreview(file, showPreview = true, streamMedia = false) file.isDown -> containerActivity?.fileOperationsHelper?.openFile(file) - canBePreviewed(file) && !file.isEncrypted -> - activity.startMediaPreview(file, 0, true, true, true, true) + activity.canMediaPreviewed(file) && !file.isEncrypted -> + activity.startMediaPreview(file, showPreview = true, streamMedia = true) else -> Log_OC.d(TAG, "Couldn't handle item click") } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 703dcc4b9f3c..465bbf594c42 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -11,9 +11,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.Configuration import android.os.Bundle import android.view.MenuItem -import android.view.View import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable @@ -22,6 +22,8 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback @@ -33,6 +35,10 @@ import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.download.SendShareDownloader +import com.nextcloud.client.player.model.file.PlaybackCollection +import com.nextcloud.client.player.model.file.toPlaybackCollection +import com.nextcloud.client.player.ui.MediaNavigator +import com.nextcloud.client.player.ui.VideoPictureInPicture import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.model.WorkerState import com.nextcloud.utils.extensions.getParcelableArgument @@ -55,13 +61,14 @@ import com.owncloud.android.ui.activity.OnFilesRemovedListener import com.owncloud.android.ui.dialog.SendShareDialog import com.owncloud.android.ui.fragment.FileFragment import com.owncloud.android.ui.fragment.GalleryFragment +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState import com.owncloud.android.ui.preview.model.PreviewImageActivityState import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import edu.umd.cs.findbugs.annotations.SuppressFBWarnings -import java.io.Serializable +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.math.max import kotlin.math.min /** @@ -74,11 +81,19 @@ class PreviewImageActivity : OnRemoteOperationListener, OnFilesRemovedListener, SendShareDialog.SendShareDialogDownloader, + MediaNavigator, Injectable { private var livePhotoFile: OCFile? = null private var viewPager: ViewPager2? = null private var previewMediaPagerAdapter: PreviewMediaPagerAdapter? = null private var savedPosition: Int? = null + private var initViewPagerJob: Job? = null + + private val onPageChangeCallback = object : OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + selectPage(position) + } + } private val sendShareDownloader by lazy { SendShareDownloader(this, localBroadcastManager) } @@ -92,6 +107,10 @@ class PreviewImageActivity : private var isDownloadWorkStarted = false private var screenState = PreviewImageActivityState.Idle + private val pictureInPicture by lazy { VideoPictureInPicture(this, playbackModel, autoEnter = false) } + private var wasSystemUiVisibleBeforePictureInPicture = true + private var keepPlaybackOnFinish = false + @Inject lateinit var preferences: AppPreferences @@ -113,8 +132,6 @@ class PreviewImageActivity : setContentView(R.layout.preview_image_activity) - livePhotoFile = intent.getParcelableArgument(EXTRA_LIVE_PHOTO_FILE, OCFile::class.java) - setupDrawer(menuItemId) val chosenFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) @@ -135,12 +152,19 @@ class PreviewImageActivity : observeWorkerState() applyDisplayCutOutTopPadding() + handleBackPress() lifecycle.addObserver(sendShareDownloader) sendShareDownloader.restoreState(savedInstanceState) } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + initializeContent(intent) + } + override fun downloadFile(file: OCFile, packageName: String, activityName: String) { sendShareDownloader.downloadFile(file, packageName, activityName) } @@ -149,23 +173,25 @@ class PreviewImageActivity : private fun applyDisplayCutOutTopPadding() { window.decorView.setOnApplyWindowInsetsListener { view, insets -> - val displayCutout = insets.displayCutout - if (displayCutout != null) { - val safeInsetTop = displayCutout.safeInsetTop - val viewPager = findViewById(R.id.fragmentPager) - viewPager.setPadding( - viewPager.paddingLeft, - safeInsetTop, - viewPager.paddingRight, - viewPager.paddingBottom - ) - viewPager.setBackgroundColor(ContextCompat.getColor(this, R.color.black)) - } - + updatePagerDisplayCutOutPadding(isInPictureInPictureMode, insets.displayCutout?.safeInsetTop ?: 0) view.onApplyWindowInsets(insets) } } + private fun updatePagerDisplayCutOutPadding(inPictureInPictureMode: Boolean, safeInsetTop: Int) { + val pager = viewPager ?: findViewById(R.id.fragmentPager) ?: return + val topPadding = if (inPictureInPictureMode) 0 else safeInsetTop + + pager.setPadding(pager.paddingLeft, topPadding, pager.paddingRight, pager.paddingBottom) + + if (topPadding > 0) { + pager.setBackgroundColor(ContextCompat.getColor(this, R.color.black)) + } + } + + private fun displayCutOutSafeInsetTop(): Int = + ViewCompat.getRootWindowInsets(window.decorView)?.displayCutout?.safeInsetTop ?: 0 + fun toggleActionBarVisibility(hide: Boolean) { if (hide) { supportActionBar?.hide() @@ -175,51 +201,47 @@ class PreviewImageActivity : } private fun initViewPager(user: User) { - val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, Serializable::class.java) - if (virtualFolderType != null && virtualFolderType !== VirtualFolderType.NONE) { - val type = virtualFolderType as VirtualFolderType - - previewMediaPagerAdapter = PreviewMediaPagerAdapter( - this, - type, - user, - storageManager, - preferences - ) - } else { - val parentFolder = file?.let { storageManager.getFileById(it.parentId) } - ?: storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + initViewPagerJob?.cancel() + initViewPagerJob = lifecycleScope.launch { + showViewPager(user, loadMediaFiles()) + } + } + + private suspend fun loadMediaFiles(): List { + val loader = PreviewMediaFilesLoader(storageManager, preferences) + val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, VirtualFolderType::class.java) - previewMediaPagerAdapter = PreviewMediaPagerAdapter( - this, - livePhotoFile, - parentFolder, - user, - storageManager, - MainApp.isOnlyOnDevice(), - preferences - ) + if (virtualFolderType != null && virtualFolderType !== VirtualFolderType.NONE) { + val mediaState = intent.getSerializableArgument(EXTRA_MEDIA_STATE, MediaState::class.java) + return loader.forVirtualFolder(virtualFolderType, mediaState) } - viewPager = findViewById(R.id.fragmentPager) + return loader.forFolder(file?.parentId, MainApp.isOnlyOnDevice()) + } + + private fun showViewPager(user: User, mediaFiles: List) { + val adapter = PreviewMediaPagerAdapter( + this, + mediaFiles, + playbackCollection(), + user, + livePhotoFile + ) + previewMediaPagerAdapter = adapter - var position = if (savedPosition != - null - ) { - savedPosition - } else { - file?.let { previewMediaPagerAdapter?.getFilePosition(it) } + val position = (savedPosition ?: file?.let { adapter.getFilePosition(it) })?.coerceAtLeast(0) + + if (savedPosition == null) { + adapter.autoplayFileId = file?.fileId } - position = position?.toDouble()?.let { max(it, 0.0).toInt() } - viewPager?.adapter = previewMediaPagerAdapter - viewPager?.registerOnPageChangeCallback(object : OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - selectPage(position) - } - }) + val pager = findViewById(R.id.fragmentPager) + viewPager = pager + pager.adapter = adapter + pager.unregisterOnPageChangeCallback(onPageChangeCallback) + pager.registerOnPageChangeCallback(onPageChangeCallback) if (position != null) { - viewPager?.setCurrentItem(position, false) + pager.setCurrentItem(position, false) } if (position == 0 && file?.isDown == false) { @@ -229,6 +251,14 @@ class PreviewImageActivity : } } + private fun playbackCollection(): PlaybackCollection { + val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, VirtualFolderType::class.java) + return virtualFolderType + ?.takeIf { it !== VirtualFolderType.NONE } + ?.toPlaybackCollection() + ?: PlaybackCollection.FOLDER + } + override fun onFilesRemoved() { initViewPager() } @@ -276,7 +306,7 @@ class PreviewImageActivity : if (isDrawerOpen) { closeDrawer() } else { - backToDisplayActivity() + onBackPressedDispatcher.onBackPressed() } return true @@ -290,29 +320,46 @@ class PreviewImageActivity : public override fun onStart() { super.onStart() registerReceivers() + initializeContent(intent) + } + + private fun initializeContent(newIntent: Intent?) { + val intent = newIntent ?: intent + livePhotoFile = intent.getParcelableArgument(EXTRA_LIVE_PHOTO_FILE, OCFile::class.java) + + val chosenFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) val optionalUser = user - if (optionalUser.isPresent) { - var file: OCFile? = file ?: throw IllegalStateException("Instanced with a NULL OCFile") - // / Validate handled file (first media item to preview) - require(MimeTypeUtil.isImageOrVideo(file)) { "Non-image/video file passed as argument" } - - // Update file according to DB file, if it is possible - if (file!!.fileId > FileDataStorageManager.ROOT_PARENT_ID) { - file = storageManager.getFileById(file.fileId) - } + if (!optionalUser.isPresent) { + finish() + return + } - if (file != null) { - // / Refresh the activity according to the Account and OCFile set - setFile(file) // reset after getting it fresh from storageManager - updateActionBarTitle(getFile()?.fileName) - if (previewMediaPagerAdapter == null) { - initViewPager(optionalUser.get()) - } + var file: OCFile? = chosenFile ?: file ?: throw IllegalStateException("Instanced with a NULL OCFile") + // updateActionBarTitle(file?.fileName) + // / Validate handled file (first media item to preview) + require(MimeTypeUtil.isImageOrVideo(file)) { "Non-image/video file passed as argument" } + + // Update file according to DB file, if it is possible + if (file!!.fileId > FileDataStorageManager.ROOT_PARENT_ID) { + file = storageManager.getFileById(file.fileId) + } + + if (file != null) { + // / Refresh the activity according to the Account and OCFile set + setFile(file) // reset after getting it fresh from storageManager + updateActionBarTitle(getFile()?.fileName) + if (previewMediaPagerAdapter == null || previewMediaPagerAdapter?.getFilePosition(file) == -1) { + savedPosition = null + initViewPager(optionalUser.get()) } else { - // handled file not in the current Account - finish() + previewMediaPagerAdapter?.getFilePosition(file)?.let { + viewPager?.currentItem = it + } } + } else { + // handled file not in the current Account + finish() } } @@ -359,20 +406,13 @@ class PreviewImageActivity : } } - private fun setDownloadedItem() { - savedPosition?.let { position -> - - previewMediaPagerAdapter?.run { - file?.let { - updateFile(position, it) - notifyItemChanged(position) - } - } + private fun setDownloadedItem(downloadedFile: OCFile?) { + val adapter = previewMediaPagerAdapter ?: return + val position = savedPosition ?: return - if (user.isPresent) { - initViewPager(user.get()) - viewPager?.currentItem = position - } + if (downloadedFile != null && adapter.getFileAt(position)?.fileId == downloadedFile.fileId) { + adapter.updateFile(position, downloadedFile) + adapter.notifyItemChanged(position) } } @@ -444,6 +484,62 @@ class PreviewImageActivity : FileDownloadHelper.instance().downloadFileIfNotStartedBefore(user, file) } + fun showFilePage(localId: Long): Boolean { + val position = previewMediaPagerAdapter?.getPositionByLocalId(localId) ?: NO_POSITION + if (position < 0) return false + if (viewPager?.currentItem != position) { + viewPager?.setCurrentItem(position, true) + } + + return true + } + + fun finishKeepingPlayback() { + keepPlaybackOnFinish = true + finish() + } + + override val hasNext: Boolean + get() = currentPage() + 1 < (previewMediaPagerAdapter?.itemCount ?: 0) + + override val hasPrevious: Boolean + get() = currentPage() > 0 + + override fun showNext() = showPage(currentPage() + 1) + + override fun showPrevious() = showPage(currentPage() - 1) + + private fun currentPage(): Int = viewPager?.currentItem ?: 0 + + private fun showPage(position: Int) { + val itemCount = previewMediaPagerAdapter?.itemCount ?: return + if (position < 0 || position >= itemCount) return + + viewPager?.setCurrentItem(position, true) + } + + fun enterPictureInPicture(): Boolean = viewPager?.let { pictureInPicture.enter(it) } == true + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + updatePagerDisplayCutOutPadding(isInPictureInPictureMode, displayCutOutSafeInsetTop()) + + if (isInPictureInPictureMode) { + wasSystemUiVisibleBeforePictureInPicture = isSystemUIVisible + toggleActionBarVisibility(true) + return + } + + toggleActionBarVisibility(!wasSystemUiVisibleBeforePictureInPicture) + + if (lifecycle.currentState != Lifecycle.State.CREATED) return + + if (!keepPlaybackOnFinish) { + playbackModel.release() + } + finish() + } + /** * This method will be invoked when a new page becomes selected. Animation is not necessarily * complete. @@ -503,7 +599,7 @@ class PreviewImageActivity : if (screenState == PreviewImageActivityState.Edit) { onImageDownloadComplete(file) } else { - setDownloadedItem() + setDownloadedItem(file) } } } @@ -563,8 +659,10 @@ class PreviewImageActivity : companion object { val TAG: String = PreviewImageActivity::class.java.simpleName const val EXTRA_VIRTUAL_TYPE: String = "EXTRA_VIRTUAL_TYPE" + const val EXTRA_MEDIA_STATE: String = "EXTRA_MEDIA_STATE" private const val KEY_WAITING_FOR_BINDER = "WAITING_FOR_BINDER" private const val KEY_SYSTEM_VISIBLE = "TRUE" + private const val NO_POSITION = -1 fun previewFileIntent(context: Context?, user: User?, file: OCFile?): Intent = Intent(context, PreviewImageActivity::class.java).apply { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index 7fd1b90db60c..587fdf35d586 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -57,6 +57,7 @@ import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSmallThumbnail import com.nextcloud.utils.extensions.typedActivity import com.owncloud.android.MainApp +import com.nextcloud.client.player.model.file.PlaybackCollection import com.owncloud.android.R import com.owncloud.android.databinding.PreviewImageFragmentBinding import com.owncloud.android.datamodel.OCFile @@ -103,7 +104,7 @@ class PreviewImageFragment : FileFragment(), Injectable { private var showResizedImage: Boolean? = null - private var bitmap: Bitmap? = null + private var ownedBitmap: Bitmap? = null private var ignoreFirstSavedState = false private var loadBitmapTask: LoadBitmapTask? = null @@ -168,7 +169,7 @@ class PreviewImageFragment : private fun playLivePhoto(file: OCFile) { hideActionBar() - val mediaFragment = PreviewPlaybackFragment.newInstance(file, searchType = null, autoplay = true) + val mediaFragment = PreviewPlaybackFragment.newInstance(file, PlaybackCollection.FOLDER, autoplay = true) requireActivity().supportFragmentManager.beginTransaction().run { replace(R.id.top, mediaFragment) addToBackStack(null) @@ -249,7 +250,6 @@ class PreviewImageFragment : binding.shimmer.visibility = View.VISIBLE binding.shimmerThumbnail.setImageBitmap(thumbnail) binding.image.visibility = View.GONE - bitmap = thumbnail } else { thumbnail = ThumbnailsCacheManager.mDefaultImg } @@ -274,8 +274,6 @@ class PreviewImageFragment : binding.emptyListView.visibility = View.GONE binding.emptyListProgress.visibility = View.GONE binding.image.setBackgroundColor(resources.getColor(R.color.background_color_inverse)) - - bitmap = resizedImage } else { // generate new resized image if (ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, binding.image) && @@ -479,7 +477,7 @@ class PreviewImageFragment : @SuppressFBWarnings("Dm") override fun onDestroy() { - bitmap?.recycle() + ownedBitmap?.recycle() super.onDestroy() } @@ -611,7 +609,7 @@ class PreviewImageFragment : showErrorMessage(mErrorMessageId) } - if (result?.bitmap != null && bitmap != result.bitmap) { + if (result?.bitmap != null && ownedBitmap != result.bitmap) { // unused bitmap, release it! (just in case) result.bitmap.recycle() } @@ -642,7 +640,7 @@ class PreviewImageFragment : imageView.setImageBitmap(bitmap) } - this@PreviewImageFragment.bitmap = bitmap // needs to be kept for recycling when not useful + this@PreviewImageFragment.ownedBitmap = bitmap } else { if (drawable != null && MIME_TYPE_SVG.equals(result.ocFile.mimeType, ignoreCase = true) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFilesLoader.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFilesLoader.kt new file mode 100644 index 000000000000..eb64e35fa5e1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFilesLoader.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.preview + +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.VirtualFolderType +import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeTypeUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class PreviewMediaFilesLoader( + private val storageManager: FileDataStorageManager, + private val preferences: AppPreferences +) { + + suspend fun forFolder(parentFolderId: Long?, onlyOnDevice: Boolean): List = withContext(Dispatchers.IO) { + val parentFolder = parentFolderId?.let { storageManager.getFileById(it) } + ?: storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + ?: return@withContext emptyList() + + val mediaFiles = storageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice).toMutableList() + + preferences.getSortOrderByFolder(parentFolder).sortCloudFiles( + mediaFiles, + preferences.isSortFoldersBeforeFiles(), + preferences.isSortFavoritesFirst() + ) + } + + suspend fun forVirtualFolder(type: VirtualFolderType, mediaState: MediaState?): List = + withContext(Dispatchers.IO) { + val mediaFiles = readVirtualFolder(type, mediaState) + + if (type != VirtualFolderType.FAVORITE) { + return@withContext FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) + } + + preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView).sortCloudFiles( + mediaFiles, + preferences.isSortFoldersBeforeFiles(), + preferences.isSortFavoritesFirst() + ) + } + + private fun readVirtualFolder(type: VirtualFolderType, mediaState: MediaState?): MutableList { + val source = if (type == VirtualFolderType.GALLERY) { + val mediaFolderPath = preferences.getLastSelectedMediaFolder() + storageManager.allGalleryItems.filter { it.remotePath.startsWith(mediaFolderPath) } + } else { + storageManager.getVirtualFolderContent(type, false) + } + + return source + .filter { + when (mediaState) { + MediaState.MEDIA_STATE_PHOTOS_ONLY -> MimeTypeUtil.isImage(it) + MediaState.MEDIA_STATE_VIDEOS_ONLY -> MimeTypeUtil.isVideo(it) + else -> MimeTypeUtil.isImageOrVideo(it) + } + } + .toMutableList() + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index fcbfdc49de69..5b96dee8ec1e 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2018 Tobias Kaminsky * SPDX-FileCopyrightText: 2020 Chris Narkiewicz * SPDX-FileCopyrightText: 2015 ownCloud Inc. @@ -15,132 +15,41 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import com.nextcloud.client.account.User -import com.nextcloud.client.preferences.AppPreferences -import com.owncloud.android.datamodel.FileDataStorageManager +import com.nextcloud.client.player.model.file.PlaybackCollection import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.ui.fragment.SearchType -import com.owncloud.android.utils.FileSortOrder -import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.MimeTypeUtil -/** - * Adapter class that provides Fragment instances - */ -class PreviewMediaPagerAdapter : FragmentStateAdapter { - - private var selectedFile: OCFile? = null - private var mediaFiles: MutableList = mutableListOf() - private val user: User - private val mObsoleteFragments: MutableSet - private val mObsoletePositions: MutableSet - private val mDownloadErrors: MutableSet - private val mStorageManager: FileDataStorageManager - private val mCachedFragments: SparseArray - - /** - * The collection the pages come from, so that a media page can build the same playback queue. - */ - private val searchType: SearchType? - - /** - * Constructor - * - * @param fragmentActivity [FragmentActivity] instance that will handle the [Fragment]s provided by the - * adapter. - * @param parentFolder Folder where images will be searched for. - * @param storageManager Bridge to database. - */ - @Suppress("LongParameterList") - constructor( - fragmentActivity: FragmentActivity, - selectedFile: OCFile?, - parentFolder: OCFile?, - user: User, - storageManager: FileDataStorageManager, - onlyOnDevice: Boolean, - preferences: AppPreferences - ) : super(fragmentActivity) { - requireNotNull(parentFolder) { "NULL parent folder" } - - this.user = user - this.selectedFile = selectedFile - mStorageManager = storageManager - searchType = null - mediaFiles = mStorageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) - - val sortOrder = preferences.getSortOrderByFolder(parentFolder) - val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() - val favoritesFirst = preferences.isSortFavoritesFirst() - mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) - - mObsoleteFragments = HashSet() - mObsoletePositions = HashSet() - mDownloadErrors = HashSet() - mCachedFragments = SparseArray() - } - - /** - * Constructor - * - * @param fragmentActivity [FragmentActivity] instance that will handle the [Fragment]s provided by the - * adapter. - * @param type Type of virtual folder, e.g. favorite or photos - * @param storageManager Bridge to database. - */ - constructor( - fragmentActivity: FragmentActivity, - type: VirtualFolderType?, - user: User, - storageManager: FileDataStorageManager, - preferences: AppPreferences - ) : super(fragmentActivity) { - requireNotNull(type) { "NULL parent folder" } - require(type != VirtualFolderType.NONE) { "NONE virtual folder type" } - - this.user = user - mStorageManager = storageManager - searchType = when (type) { - VirtualFolderType.GALLERY -> SearchType.GALLERY_SEARCH - VirtualFolderType.FAVORITE -> SearchType.FAVORITE_SEARCH - else -> null - } - - if (type == VirtualFolderType.GALLERY) { - mediaFiles = mStorageManager.allGalleryItems - mediaFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) - } else { - mediaFiles = mStorageManager.getVirtualFolderContent(type, true) - } +class PreviewMediaPagerAdapter( + fragmentActivity: FragmentActivity, + files: List, + private val playbackCollection: PlaybackCollection, + private val user: User, + private val selectedFile: OCFile? = null +) : FragmentStateAdapter(fragmentActivity) { - if (type == VirtualFolderType.FAVORITE) { - val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) - val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() - val favoritesFirst = preferences.isSortFavoritesFirst() - mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) - } + var autoplayFileId: Long? = null - mObsoleteFragments = HashSet() - mObsoletePositions = HashSet() - mDownloadErrors = HashSet() - mCachedFragments = SparseArray() - } + private val mediaFiles: MutableList = files.toMutableList() + private val obsoleteFragments: MutableSet = HashSet() + private val obsoletePositions: MutableSet = HashSet() + private val downloadErrors: MutableSet = HashSet() + private val cachedFragments: SparseArray = SparseArray() fun delete(position: Int) { if (position < 0 || position >= mediaFiles.size) { return } - mCachedFragments[position]?.let { - mObsoleteFragments.add(it) + cachedFragments[position]?.let { + obsoleteFragments.add(it) } - mObsoletePositions.add(position) + obsoletePositions.add(position) mediaFiles.removeAt(position) - mDownloadErrors.remove(position) - mCachedFragments.remove(position) + downloadErrors.remove(position) + cachedFragments.remove(position) notifyItemRemoved(position) } @@ -157,8 +66,8 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } fun getItem(i: Int): Fragment { - val fragment = fragmentFor(getFileAt(i), i, mObsoletePositions.contains(i)) - mObsoletePositions.remove(i) + val fragment = fragmentFor(getFileAt(i), i, obsoletePositions.contains(i)) + obsoletePositions.remove(i) return fragment } @@ -170,7 +79,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { private fun fragmentForDownloaded(file: OCFile, ignoreFirstSavedState: Boolean): Fragment = if (file.isAudioOrVideo()) { - PreviewPlaybackFragment.newInstance(file, searchType) + PreviewPlaybackFragment.newInstance(file, playbackCollection, takeAutoplay(file)) } else { PreviewImageFragment.newInstance(file, ignoreFirstSavedState, false) } @@ -179,34 +88,45 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { addVideoOfLivePhoto(file) return when { - mDownloadErrors.remove(position) -> + downloadErrors.remove(position) -> FileDownloadFragment.newInstance(file, user, true).apply { setError(true) } // The FileDownloadFragment is used exclusively for encrypted files, as they cannot be previewed // without first being downloaded. file.isEncrypted -> FileDownloadFragment.newInstance(file, user, ignoreFirstSavedState) - file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, searchType) + file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, playbackCollection, takeAutoplay(file)) else -> PreviewImageFragment.newInstance(file, ignoreFirstSavedState, true) } } + private fun takeAutoplay(file: OCFile): Boolean { + if (file.fileId != autoplayFileId) { + return false + } + + autoplayFileId = null + return true + } + private fun OCFile.isAudioOrVideo(): Boolean = MimeTypeUtil.isAudio(this) || MimeTypeUtil.isVideo(this) fun getFilePosition(file: OCFile): Int = mediaFiles.indexOf(file) + fun getPositionByLocalId(localId: Long): Int = mediaFiles.indexOfFirst { it.localId == localId } + fun updateFile(position: Int, file: OCFile) { if (position < 0 || position >= mediaFiles.size) { return } - mCachedFragments[position]?.let { mObsoleteFragments.add(it) } - mObsoletePositions.add(position) + cachedFragments[position]?.let { obsoleteFragments.add(it) } + obsoletePositions.add(position) mediaFiles[position] = file } - fun pendingErrorAt(position: Int): Boolean = mDownloadErrors.contains(position) + fun pendingErrorAt(position: Int): Boolean = downloadErrors.contains(position) override fun createFragment(position: Int): Fragment = getItem(position) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt index f89d1a5f07df..9da820b28125 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -9,27 +9,53 @@ package com.owncloud.android.ui.preview import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.core.content.ContextCompat import androidx.core.os.bundleOf +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager 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.PlaybackCollection import com.nextcloud.client.player.model.file.PlaybackFile -import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.util.PlayerUtil.toPlaybackFile import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.model.state.VideoSize +import com.nextcloud.client.player.ui.MediaNavigator import com.nextcloud.client.player.ui.PlayerLauncher -import com.nextcloud.client.player.util.applyVideoSize +import com.nextcloud.client.player.util.PlayerUtil.applyVideoSize +import com.nextcloud.client.player.util.PlayerUtil.isPictureInPictureAllowed +import com.nextcloud.client.player.util.PlayerUtil.ownsPlayback +import com.nextcloud.ui.fileactions.FileAction +import com.nextcloud.ui.fileactions.FileActionsBottomSheet import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument import com.owncloud.android.R import com.owncloud.android.databinding.PreviewPlaybackFragmentBinding import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.ui.fragment.SearchType +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.FetchRemoteFileOperation +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -42,18 +68,28 @@ class PreviewPlaybackFragment : PlaybackModel.Listener { companion object { + private val TAG = PreviewPlaybackFragment::class.java.simpleName private const val ARGUMENT_FILE = "ARGUMENT_FILE" - private const val ARGUMENT_SEARCH_TYPE = "ARGUMENT_SEARCH_TYPE" + private const val ARGUMENT_COLLECTION = "ARGUMENT_COLLECTION" private const val ARGUMENT_AUTOPLAY = "ARGUMENT_AUTOPLAY" + private const val ARGUMENT_PICTURE_IN_PICTURE_ON_BACK = "ARGUMENT_PICTURE_IN_PICTURE_ON_BACK" + private const val SURFACE_ALPHA_VISIBLE = 1f + private const val SURFACE_ALPHA_HIDDEN = 0f - fun newInstance(file: OCFile, searchType: SearchType?, autoplay: Boolean = false) = - PreviewPlaybackFragment().apply { - arguments = bundleOf( - ARGUMENT_FILE to file, - ARGUMENT_SEARCH_TYPE to searchType, - ARGUMENT_AUTOPLAY to autoplay - ) - } + @Suppress("LongParameterList") + fun newInstance( + file: OCFile, + collection: PlaybackCollection, + autoplay: Boolean = false, + pictureInPictureOnBack: Boolean = true + ) = PreviewPlaybackFragment().apply { + arguments = bundleOf( + ARGUMENT_FILE to file, + ARGUMENT_COLLECTION to collection, + ARGUMENT_AUTOPLAY to autoplay, + ARGUMENT_PICTURE_IN_PICTURE_ON_BACK to pictureInPictureOnBack + ) + } } @Inject @@ -63,14 +99,27 @@ class PreviewPlaybackFragment : lateinit var playerLauncher: PlayerLauncher @Inject - lateinit var thumbnailLoader: ThumbnailLoader + lateinit var playerThumbnailLoader: PlayerThumbnailLoader + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils private lateinit var binding: PreviewPlaybackFragmentBinding private lateinit var file: OCFile private lateinit var playbackFile: PlaybackFile - private var searchType: SearchType? = null + private var playbackCollection = PlaybackCollection.FOLDER private var autoplay: Boolean = false - private var previousVideoSize: VideoSize? = null + private var pictureInPictureOnBack: Boolean = true + private var wasCurrentItem = false + private var renderedVideoSize: VideoSize? = null + + private var pictureInPictureCallback: OnBackPressedCallback? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -78,34 +127,216 @@ class PreviewPlaybackFragment : file = arguments.getParcelableArgument(ARGUMENT_FILE, OCFile::class.java) ?: throw IllegalArgumentException("bundle is not containing a file") playbackFile = file.toPlaybackFile() - searchType = arguments.getSerializableArgument(ARGUMENT_SEARCH_TYPE, SearchType::class.java) + playbackCollection = arguments.getSerializableArgument(ARGUMENT_COLLECTION, PlaybackCollection::class.java) + ?: PlaybackCollection.FOLDER autoplay = arguments?.getBoolean(ARGUMENT_AUTOPLAY) == true + pictureInPictureOnBack = arguments?.getBoolean(ARGUMENT_PICTURE_IN_PICTURE_ON_BACK) != false } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = PreviewPlaybackFragmentBinding.inflate(inflater, container, false) loadThumbnail() + registerPictureInPictureOnBack() + binding.playerControlView.navigator = activity as? MediaNavigator + updatePlayerControlsVisibility() + binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (ownsPlayback(binding.surfaceView)) render(playbackModel.state) + } return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.custom_menu_placeholder, menu) + val item = menu.findItem(R.id.custom_menu_placeholder_item) + item.icon?.let { + item.setIcon( + viewThemeUtils.platform.colorDrawable( + it, + ContextCompat.getColor(requireContext(), R.color.white) + ) + ) + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.custom_menu_placeholder_item -> { + onOverflowClick() + true + } + + else -> false + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private fun onOverflowClick(isManualClick: Boolean = false) { + val storageManager = previewActivity()?.storageManager ?: return + val updatedFile = storageManager.getFileById(file.fileId) + + // check for albums file for album file both local and remoteId will be same configured at operation level + if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { + fetchFileMetaDataIfAbsent(updatedFile) + return + } + + updatedFile?.let { actionsFile -> + val additionalFilter = FileAction.getFilePreviewActions(actionsFile) + FileActionsBottomSheet.newInstance(actionsFile, false, additionalFilter) + .setResultListener(childFragmentManager, viewLifecycleOwner) { itemId: Int -> + onFileActionChosen(itemId) + } + .show(childFragmentManager, "actions") + } + } + + private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { + val previewActivity = previewActivity() ?: return + val context = context ?: return + + previewActivity.showLoadingDialog(getString(R.string.wait_a_moment)) + lifecycleScope.launch(Dispatchers.IO) { + val operation = FetchRemoteFileOperation( + context, + accountManager.user, + ocFile, + removeFileFromDb = true, + storageManager = previewActivity.storageManager + ) + val result = operation.execute(context) + + withContext(Dispatchers.Main) { + previewActivity.dismissLoadingDialog() + + if (result?.isSuccess == true && result.resultData != null) { + file = result.resultData as OCFile + onOverflowClick(isManualClick = true) + } else { + Log_OC.d(TAG, result?.logMessage) + DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(context)) + } + } + } + } + + @Suppress("CyclomaticComplexMethod") + private fun onFileActionChosen(itemId: Int) { + val previewActivity = previewActivity() ?: return + val fileOperationsHelper = previewActivity.fileOperationsHelper + + when (itemId) { + R.id.action_see_details -> previewActivity.showDetails(file) + + R.id.action_download_file -> previewActivity.requestForDownload(file) + + R.id.action_export_file -> fileOperationsHelper.exportFiles( + arrayListOf(file), + context, + view, + backgroundJobManager + ) + + R.id.action_send_share_file -> if (file.isSharedWithMe && !file.canReshare()) { + Snackbar.make(requireView(), R.string.resharing_is_not_allowed, Snackbar.LENGTH_LONG).show() + } else { + fileOperationsHelper.sendShareFile(file) + } + + R.id.action_send_file -> fileOperationsHelper.sendShareFile(file, true) + + R.id.action_open_file_with -> fileOperationsHelper.openFile(file) + + R.id.action_stream_media -> { + playbackModel.pause() + fileOperationsHelper.streamMediaFile(file) + } + + R.id.action_remove_file -> { + playbackModel.pause() + RemoveFilesDialogFragment.newInstance( + file + ).show(parentFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) + } + + R.id.action_add_to_album -> fileOperationsHelper.addFileToAlbum(listOf(file)) + + R.id.action_lock_file -> fileOperationsHelper.toggleFileLock(file, true) + + R.id.action_unlock_file -> fileOperationsHelper.toggleFileLock(file, false) + } + } + + private fun registerPictureInPictureOnBack() { + if (!pictureInPictureOnBack || !MimeTypeUtil.isVideo(file)) { + return + } + + if (!requireContext().isPictureInPictureAllowed()) { + return + } + + pictureInPictureCallback = requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + enabled = false + ) { + if (previewActivity()?.enterPictureInPicture() == true) { + return@addCallback + } + + isEnabled = false + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + override fun onStart() { + super.onStart() + playbackModel.addListener(this) + render(playbackModel.state) + } + override fun onResume() { super.onResume() + playbackModel.onPictureInPictureClose?.invoke() + pictureInPictureCallback?.isEnabled = true preparePlayback() - playbackModel.addListener(this) binding.playerControlView.onStart() render(playbackModel.state) } override fun onPause() { + pictureInPictureCallback?.isEnabled = false binding.playerControlView.onStop() - playbackModel.removeListener(this) - if (isCurrentItem(playbackModel.state)) { + if (!isInPictureInPictureMode() && isCurrentItem(playbackModel.state)) { playbackModel.pause() playbackModel.setVideoSurfaceView(null) } super.onPause() } + override fun onStop() { + playbackModel.removeListener(this) + super.onStop() + } + + override fun onDestroyView() { + playbackModel.clearVideoSurfaceView(binding.surfaceView) + binding.playerControlView.navigator = null + pictureInPictureCallback?.remove() + pictureInPictureCallback = null + super.onDestroyView() + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + render(playbackModel.state) + } + override fun onPlaybackUpdate(state: PlaybackState) { render(state) } @@ -122,7 +353,7 @@ class PreviewPlaybackFragment : playbackModel.play() } } else { - playerLauncher.prepare(this, file, searchType, autoplay) + playerLauncher.prepare(this, file, playbackCollection, autoplay) } } @@ -132,26 +363,59 @@ class PreviewPlaybackFragment : viewLifecycleOwner.lifecycleScope.launch { val context = context ?: return@launch val size = context.resources.getDimension(R.dimen.player_album_cover_size).toInt() - thumbnailLoader.await(context, playbackFile, size, size)?.let(binding.thumbnail::setImageBitmap) + playerThumbnailLoader.await(playbackFile, size, size)?.let(binding.thumbnail::setImageBitmap) } } private fun render(state: PlaybackState?) { - if (!isCurrentItem(state)) { - binding.surfaceView.visibility = View.GONE + updatePlayerControlsVisibility() + + if (isCurrentItem(state)) { + wasCurrentItem = true + showVideo(state?.currentItemState?.videoSize) return } - showVideo(state?.currentItemState?.videoSize) + + val playbackMovedOn = wasCurrentItem && ownsPlayback(binding.surfaceView) + wasCurrentItem = false + renderedVideoSize = null + binding.surfaceView.visibility = View.GONE + if (playbackMovedOn) { + showPageOfCurrentItem(state) + } + } + + private fun showPageOfCurrentItem(state: PlaybackState?) { + val previewActivity = previewActivity() ?: return + val localId = state?.currentItemState?.file?.id?.toLongOrNull() + if (localId != null && previewActivity.showFilePage(localId)) return + + if (isInPictureInPictureMode()) { + previewActivity.finishKeepingPlayback() + } + } + + private fun previewActivity(): PreviewImageActivity? = activity as? PreviewImageActivity + + private fun isInPictureInPictureMode(): Boolean = activity?.isInPictureInPictureMode == true + + private fun updatePlayerControlsVisibility() { + binding.playerControlView.isVisible = !isInPictureInPictureMode() } private fun showVideo(videoSize: VideoSize?) { - playbackModel.setVideoSurfaceView(binding.surfaceView) + if (ownsPlayback(binding.surfaceView)) { + playbackModel.setVideoSurfaceView(binding.surfaceView) + } + + val size = videoSize ?: renderedVideoSize + renderedVideoSize = size + binding.surfaceView.visibility = View.VISIBLE - binding.surfaceView.alpha = if (videoSize != null) 1f else 0f + binding.surfaceView.alpha = if (size != null) SURFACE_ALPHA_VISIBLE else SURFACE_ALPHA_HIDDEN - if (videoSize != null && previousVideoSize != videoSize) { - previousVideoSize = videoSize - binding.surfaceView.applyVideoSize(videoSize) + if (size != null) { + binding.surfaceView.applyVideoSize(size) } } } diff --git a/app/src/main/res/drawable/player_ic_audio.xml b/app/src/main/res/drawable/player_ic_audio.xml index ff60b2bdbb9a..dd9b509539b9 100644 --- a/app/src/main/res/drawable/player_ic_audio.xml +++ b/app/src/main/res/drawable/player_ic_audio.xml @@ -1,18 +1,9 @@ + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2026 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + android:paddingStart="@dimen/player_file_fragment_padding_horizontal" + android:paddingEnd="@dimen/player_file_fragment_padding_horizontal"> + app:shapeAppearance="@style/ShapeAppearance.App.Player.Media" /> @@ -15,7 +16,7 @@ android:id="@+id/progressPanel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/player_progress_panel_margin_bottom" + android:layout_marginBottom="@dimen/player_panel_margin_bottom" android:gravity="center_vertical" android:orientation="horizontal" android:paddingStart="@dimen/player_progress_panel_padding_horizontal" @@ -43,7 +44,7 @@ android:id="@+id/bottomBar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/player_bottom_bar_margin_bottom" + android:paddingBottom="@dimen/standard_padding" android:gravity="center" android:orientation="horizontal"> diff --git a/app/src/main/res/layout/player_pager.xml b/app/src/main/res/layout/player_pager.xml deleted file mode 100644 index 2b953b9b2dc6..000000000000 --- a/app/src/main/res/layout/player_pager.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/layout/player_video_file_fragment.xml b/app/src/main/res/layout/player_video_file_fragment.xml index 9f126e01ea20..5acc0abcdcff 100644 --- a/app/src/main/res/layout/player_video_file_fragment.xml +++ b/app/src/main/res/layout/player_video_file_fragment.xml @@ -1,11 +1,12 @@ @@ -18,11 +19,12 @@ android:importantForAccessibility="no" android:src="@drawable/video_white" /> - + android:importantForAccessibility="no" + app:shapeAppearance="@style/ShapeAppearance.App.Player.Media" /> - - 56dp - 8dp - 0dp - 8dp - 0dp - 8dp + 8dp + 0dp + 8dp \ No newline at end of file diff --git a/app/src/main/res/values-large-land/dims.xml b/app/src/main/res/values-large-land/dims.xml index 07ce8f787cad..2ed7cf7567d2 100644 --- a/app/src/main/res/values-large-land/dims.xml +++ b/app/src/main/res/values-large-land/dims.xml @@ -1,16 +1,12 @@ - - 56dp - 24dp - 8dp - 24dp - 16dp - 16dp + 24dp + 8dp + 24dp \ No newline at end of file diff --git a/app/src/main/res/values-large/dims.xml b/app/src/main/res/values-large/dims.xml index 3a56e875553b..b7f1e53ab405 100644 --- a/app/src/main/res/values-large/dims.xml +++ b/app/src/main/res/values-large/dims.xml @@ -1,18 +1,14 @@ - - 64dp 12dp - 24dp - 24dp - 48dp - 48dp + 24dp + 24dp + 48dp 40dp - 72dp \ No newline at end of file diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 5e8e56a0f69a..9d26887845be 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -160,4 +160,31 @@ 18dp 24dp 4dp + + + 56dp + 21sp + 24dp + 4dp + 12dp + 16dp + 16dp + 16sp + 8dp + 12sp + 32dp + 350dp + 96dp + 24dp + @dimen/standard_padding + @dimen/standard_padding + 2dp + 4dp + 12dp + 24dp + 1dp + 12dp + 15sp + 16dp + 8dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index f626f7d2929e..f939274621ff 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -42,4 +42,7 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62321a715267..c1589ad6f91d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -431,18 +431,6 @@ Passcode deleted Passcode stored - %1$s music player - %1$s (playing) - Unsupported media codec - Could not read the media file - The media file has incorrect encoding - Attempt to play file timed out - The media file cannot be streamed - The built-in media player is unable to play the media file - Rewind button - Play or pause button - Fast forward button - No network connection Secure connection unavailable. Connection established @@ -512,12 +500,6 @@ - The URL does not match the hostname in the certificate Do you want to trust this certificate anyway? Could not save certificate - Seek forward - Seek backward - Play - Pause - Playing media - Details Hide Issued to: @@ -1120,7 +1102,6 @@ Delete permanently Stream with… Internal streaming not possible - Please download media instead or use external app. Folder already exists Notification icon Create @@ -1333,8 +1314,6 @@ 4 hours This week Media - stop - toggle Choose which file to keep! Data storage folder does not exist! This might be due to a backup restore on another device. Falling back to default. Please check settings to adjust data storage folder. @@ -1645,4 +1624,18 @@ Share link to album Stop sharing Everyone can only view + + + Export started (%d file) + Exports started (%d files) + + Source not found + Close + Modified: %s + %1$s • %2$s + Repeat button + Previous button + Play/Pause button + Next button + Random button diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 94cc465d0f53..218a002a027b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -519,6 +519,7 @@ true end center + @color/player_default_text_color @style/TextAppearance.App.Player.ProgressTime @@ -549,7 +550,7 @@ @dimen/player_audio_file_details_text_size - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09518c9eae68..7e37a2b7cd74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -241,7 +241,6 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "w foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesVersion" } -kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesVersion" } [bundles] media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource"]