diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c28ced6a35b6..daa3fd97efc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -526,6 +526,8 @@ dependencies { // region Kotlin implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.guava) // endregion // region Stateless diff --git a/app/lint.xml b/app/lint.xml index 2316b422a838..848a5919cd87 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -76,6 +76,11 @@ + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 30f0dba91170..d01cf0ce6cb9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -387,13 +387,13 @@ android:exported="false" android:theme="@style/Theme.ownCloud.Overlay" /> + android:name="com.nextcloud.client.player.ui.PlayerActivity" + android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" + android:launchMode="singleTask" + android:supportsPictureInPicture="true" /> @@ -402,6 +402,13 @@ + + + + + + @@ -555,11 +562,6 @@ android:name="com.nextcloud.client.jobs.transfer.FileTransferService" android:exported="false" android:foregroundServiceType="dataSync" /> - - + @Query( + """ + SELECT * + FROM filelist + WHERE file_owner = :fileOwner + AND favorite = 1 + ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER} + """ + ) + fun getFavoriteFilesNonBlocking(fileOwner: String): List + @Query("SELECT remote_id FROM filelist WHERE file_owner = :accountName AND remote_id IS NOT NULL") fun getAllRemoteIds(accountName: String): List diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.java b/app/src/main/java/com/nextcloud/client/di/AppComponent.java deleted file mode 100644 index 8e1f599e5723..000000000000 --- a/app/src/main/java/com/nextcloud/client/di/AppComponent.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.nextcloud.client.di; - -import android.app.Application; - -import com.nextcloud.appReview.InAppReviewModule; -import com.nextcloud.client.appinfo.AppInfoModule; -import com.nextcloud.client.database.DatabaseModule; -import com.nextcloud.client.device.DeviceModule; -import com.nextcloud.client.integrations.IntegrationsModule; -import com.nextcloud.client.jobs.JobsModule; -import com.nextcloud.client.jobs.download.FileDownloadHelper; -import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver; -import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver; -import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver; -import com.nextcloud.client.jobs.upload.FileUploadHelper; -import com.nextcloud.client.media.BackgroundPlayerService; -import com.nextcloud.client.network.NetworkModule; -import com.nextcloud.client.onboarding.OnboardingModule; -import com.nextcloud.client.preferences.PreferencesModule; -import com.owncloud.android.MainApp; -import com.owncloud.android.media.MediaControlView; -import com.owncloud.android.ui.ThemeableSwitchPreference; -import com.owncloud.android.ui.whatsnew.ProgressIndicator; - -import javax.inject.Singleton; - -import androidx.annotation.OptIn; -import androidx.media3.common.util.UnstableApi; -import dagger.BindsInstance; -import dagger.Component; -import dagger.android.support.AndroidSupportInjectionModule; - -@Component(modules = { - AndroidSupportInjectionModule.class, - AppModule.class, - PreferencesModule.class, - AppInfoModule.class, - NetworkModule.class, - DeviceModule.class, - OnboardingModule.class, - ViewModelModule.class, - JobsModule.class, - IntegrationsModule.class, - InAppReviewModule.class, - ThemeModule.class, - DatabaseModule.class, - DispatcherModule.class, - VariantModule.class, -}) -@Singleton -public interface AppComponent { - - void inject(MainApp app); - - void inject(MediaControlView mediaControlView); - - @OptIn(markerClass = UnstableApi.class) - void inject(BackgroundPlayerService backgroundPlayerService); - - void inject(ThemeableSwitchPreference switchPreference); - - void inject(FileUploadHelper fileUploadHelper); - - void inject(FileDownloadHelper fileDownloadHelper); - - void inject(ProgressIndicator progressIndicator); - - void inject(FileUploadBroadcastReceiver fileUploadBroadcastReceiver); - - void inject(OfflineOperationReceiver offlineOperationReceiver); - - void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver); - - @Component.Builder - interface Builder { - @BindsInstance - Builder application(Application application); - - AppComponent build(); - } -} diff --git a/app/src/main/java/com/nextcloud/client/di/AppComponent.kt b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt new file mode 100644 index 000000000000..de3a4edd9688 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/AppComponent.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.client.di + +import android.app.Application +import com.nextcloud.appReview.InAppReviewModule +import com.nextcloud.client.appinfo.AppInfoModule +import com.nextcloud.client.database.DatabaseModule +import com.nextcloud.client.device.DeviceModule +import com.nextcloud.client.integrations.IntegrationsModule +import com.nextcloud.client.jobs.JobsModule +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver +import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver +import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.nextcloud.client.network.NetworkModule +import com.nextcloud.client.onboarding.OnboardingModule +import com.nextcloud.client.player.PlayerModule +import com.nextcloud.client.preferences.PreferencesModule +import com.owncloud.android.MainApp +import com.owncloud.android.ui.ThemeableSwitchPreference +import com.owncloud.android.ui.whatsnew.ProgressIndicator +import dagger.BindsInstance +import dagger.Component +import dagger.android.support.AndroidSupportInjectionModule +import javax.inject.Singleton + +@Component( + modules = [ + AndroidSupportInjectionModule::class, + AppModule::class, + PreferencesModule::class, + AppInfoModule::class, + NetworkModule::class, + DeviceModule::class, + OnboardingModule::class, + ViewModelModule::class, + JobsModule::class, + IntegrationsModule::class, + InAppReviewModule::class, + ThemeModule::class, + DatabaseModule::class, + DispatcherModule::class, + VariantModule::class, + PlayerModule::class + ] +) +@Singleton +interface AppComponent { + fun inject(app: MainApp) + + fun inject(switchPreference: ThemeableSwitchPreference) + + fun inject(fileUploadHelper: FileUploadHelper) + + fun inject(fileDownloadHelper: FileDownloadHelper) + + fun inject(progressIndicator: ProgressIndicator) + + fun inject(fileUploadBroadcastReceiver: FileUploadBroadcastReceiver) + + fun inject(offlineOperationReceiver: OfflineOperationReceiver) + + fun inject(folderDownloadWorkerReceiver: FolderDownloadWorkerReceiver) + + @Component.Builder + interface Builder { + @BindsInstance + fun application(application: Application): Builder + + fun build(): AppComponent + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 7cd7c1112326..99cb9f448a6d 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -18,8 +18,6 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.logger.ui.LogsActivity; import com.nextcloud.client.logger.ui.LogsViewModel; -import com.nextcloud.client.media.BackgroundPlayerService; -import com.nextcloud.client.media.PlayerService; import com.nextcloud.client.migrations.Migrations; import com.nextcloud.client.onboarding.FirstRunActivity; import com.nextcloud.client.onboarding.WhatsNewActivity; @@ -122,8 +120,7 @@ import com.owncloud.android.ui.preview.PreviewBitmapActivity; import com.owncloud.android.ui.preview.PreviewImageActivity; import com.owncloud.android.ui.preview.PreviewImageFragment; -import com.owncloud.android.ui.preview.PreviewMediaActivity; -import com.owncloud.android.ui.preview.PreviewMediaFragment; +import com.owncloud.android.ui.preview.PreviewPlaybackFragment; import com.owncloud.android.ui.preview.PreviewTextFileFragment; import com.owncloud.android.ui.preview.PreviewTextFragment; import com.owncloud.android.ui.preview.PreviewTextStringFragment; @@ -206,9 +203,6 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract PreviewImageActivity previewImageActivity(); - @ContributesAndroidInjector - abstract PreviewMediaActivity previewMediaActivity(); - @ContributesAndroidInjector abstract ReceiveExternalFilesActivity receiveExternalFilesActivity(); @@ -290,9 +284,6 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract BackupListFragment chooseContactListFragment(); - @ContributesAndroidInjector - abstract PreviewMediaFragment previewMediaFragment(); - @ContributesAndroidInjector abstract PreviewTextFragment previewTextFragment(); @@ -302,6 +293,9 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract SetOnlineStatusBottomSheet setOnlineStatusBottomSheet(); + @ContributesAndroidInjector + abstract PreviewPlaybackFragment previewPlaybackFragment(); + @ContributesAndroidInjector abstract PreviewTextFileFragment previewTextFileFragment(); @@ -344,9 +338,6 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract OperationsService operationsService(); - @ContributesAndroidInjector - abstract PlayerService playerService(); - @ContributesAndroidInjector abstract FileTransferService fileDownloaderService(); @@ -498,9 +489,6 @@ abstract class ComponentsModule { abstract InternalTwoWaySyncActivity internalTwoWaySyncActivity(); @OptIn(markerClass = UnstableApi.class) - @ContributesAndroidInjector - abstract BackgroundPlayerService backgroundPlayerService(); - @ContributesAndroidInjector abstract TermsOfServiceDialog termsOfServiceDialog(); diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index eb3e98a6c570..53c349fd0b9f 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.ViewModelProvider import com.nextcloud.client.documentscan.DocumentScanViewModel import com.nextcloud.client.etm.EtmViewModel import com.nextcloud.client.logger.ui.LogsViewModel +import com.nextcloud.client.player.ui.PlayerViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel @@ -57,6 +58,11 @@ abstract class ViewModelModule { @ViewModelKey(TrashbinFileActionsViewModel::class) abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(PlayerViewModel::class) + abstract fun playerViewModel(vm: PlayerViewModel): ViewModel + @Binds abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory } diff --git a/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt b/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt deleted file mode 100644 index c60702bb9b47..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/AudioFocus.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioManager - -/** - * Simplified audio focus values, relevant to application's media player experience. - */ -internal enum class AudioFocus { - - LOST, - DUCK, - FOCUS; - - companion object { - fun fromPlatformFocus(audioFocus: Int): AudioFocus? = when (audioFocus) { - AudioManager.AUDIOFOCUS_GAIN -> FOCUS - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> FOCUS - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> FOCUS - AudioManager.AUDIOFOCUS_LOSS -> LOST - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> LOST - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> DUCK - else -> null - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt b/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt deleted file mode 100644 index 3cd1e1da7166..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioFocusRequest -import android.media.AudioManager - -/** - * Wrapper around audio manager exposing simplified audio focus API and - * hiding platform API level differences. - * - * @param audioManger Platform audio manager - * @param onFocusChange Called when audio focus changes, including acquired and released focus states - */ -internal class AudioFocusManager( - private val audioManger: AudioManager, - private val onFocusChange: (AudioFocus) -> Unit, - requestBuilder: AudioFocusRequest.Builder = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) -) { - private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> - val focus = when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> AudioFocus.FOCUS - - AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> AudioFocus.LOST - - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> AudioFocus.DUCK - - else -> null - } - focus?.let { onFocusChange(it) } - } - - private val focusRequest = requestBuilder - .setWillPauseWhenDucked(true) - .setOnAudioFocusChangeListener(focusListener) - .build() - - fun requestFocus() { - val requestResult = audioManger.requestAudioFocus(focusRequest) - if (requestResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) - } else { - focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) - } - } - - fun releaseFocus() { - audioManger.abandonAudioFocusRequest(focusRequest) - focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt b/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt deleted file mode 100644 index 1d3517400603..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-FileCopyrightText: 2024 Parneet Singh - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.client.media - -import android.app.NotificationManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.Bundle -import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat -import androidx.media3.common.Player -import androidx.media3.common.Player.COMMAND_PLAY_PAUSE -import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT -import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS -import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSession.ConnectionResult -import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder -import androidx.media3.session.MediaSessionService -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.di.Injectable -import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.common.NextcloudClient -import com.nextcloud.utils.extensions.registerBroadcastReceiver -import com.owncloud.android.MainApp -import com.owncloud.android.R -import com.owncloud.android.datamodel.ReceiverFlag -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.notifications.NotificationUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@OptIn(UnstableApi::class) -class BackgroundPlayerService : - MediaSessionService(), - Injectable { - - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - private val seekBackSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_BACK, Bundle.EMPTY) - private val seekForwardSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_FORWARD, Bundle.EMPTY) - - private lateinit var seekForward: CommandButton - private lateinit var seekBackward: CommandButton - - @Inject - lateinit var clientFactory: ClientFactory - - @Inject - lateinit var userAccountManager: UserAccountManager - - private lateinit var exoPlayer: ExoPlayer - private var mediaSession: MediaSession? = null - private var isPlayerReady = false - - private val stopReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - RELEASE_MEDIA_SESSION_BROADCAST_ACTION -> release() - - STOP_MEDIA_SESSION_BROADCAST_ACTION -> { - if (isPlayerReady) { - exoPlayer.stop() - } else { - stopSelf() - } - } - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val notification = NotificationCompat.Builder(this, NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) - .setSmallIcon(R.drawable.logo) - .setContentTitle(getString(R.string.media_player_playing)) - .setSilent(true) - .build() - - ServiceCompat.startForeground( - this, - DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID, - notification, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - } else { - 0 - } - ) - - return super.onStartCommand(intent, flags, startId) - } - - @Suppress("DEPRECATION") - override fun onCreate() { - super.onCreate() - - MainApp.getAppComponent().inject(this) - - if (userAccountManager.allUsers.isEmpty()) { - return - } - - seekForward = CommandButton.Builder() - .setDisplayName(getString(R.string.media_player_seek_forward)) - .setIconResId(R.drawable.ic_skip_next) - .setSessionCommand(seekForwardSessionCommand) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 2) }) - .build() - - seekBackward = CommandButton.Builder() - .setDisplayName(getString(R.string.media_player_seek_backward)) - .setIconResId(R.drawable.ic_skip_previous) - .setSessionCommand(seekBackSessionCommand) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 0) }) - .build() - - exoPlayer = ExoPlayer.Builder(this).build() - mediaSession = buildMediaSession(exoPlayer) - - setMediaNotificationProvider(buildNotificationProvider()) - - registerBroadcastReceiver( - stopReceiver, - IntentFilter().apply { - addAction(RELEASE_MEDIA_SESSION_BROADCAST_ACTION) - addAction(STOP_MEDIA_SESSION_BROADCAST_ACTION) - }, - ReceiverFlag.NotExported - ) - - initExoPlayer() - } - - @Suppress("TooGenericExceptionCaught") - private fun initExoPlayer() { - serviceScope.launch { - try { - val nextcloudClient: NextcloudClient = withContext(Dispatchers.IO) { - clientFactory.createNextcloudClient(userAccountManager.user) - } - - val realPlayer = createNextcloudExoplayer(this@BackgroundPlayerService, nextcloudClient) - exoPlayer.release() - exoPlayer = realPlayer - isPlayerReady = true - - // Update the session to use the real player - mediaSession?.player = realPlayer - } catch (e: Exception) { - Log_OC.e(TAG, "Failed to initialise Nextcloud ExoPlayer: ${e.message}") - stopSelf() - } - } - } - - private fun buildMediaSession(player: ExoPlayer): MediaSession = MediaSession.Builder(applicationContext, player) - .setId(BACKGROUND_MEDIA_SESSION_ID) - .setCustomLayout(listOf(seekBackward, seekForward)) - .setCallback(object : MediaSession.Callback { - override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): ConnectionResult = - AcceptedResultBuilder(mediaSession ?: session) - .setAvailablePlayerCommands( - ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() - .remove(COMMAND_SEEK_TO_NEXT) - .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) - .remove(COMMAND_SEEK_TO_PREVIOUS) - .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) - .build() - ) - .setAvailableSessionCommands( - ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() - .addSessionCommands( - listOf(seekBackSessionCommand, seekForwardSessionCommand) - ).build() - ) - .build() - - override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { - session.setCustomLayout(listOf(seekBackward, seekForward)) - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = when (customCommand.customAction) { - SESSION_COMMAND_ACTION_SEEK_FORWARD -> { - session.player.seekForward() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - SESSION_COMMAND_ACTION_SEEK_BACK -> { - session.player.seekBack() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - else -> super.onCustomCommand(session, controller, customCommand, args) - } - }) - .build() - - private fun buildNotificationProvider() = object : DefaultMediaNotificationProvider(this) { - @Suppress("DEPRECATION") - override fun getMediaButtons( - session: MediaSession, - playerCommands: Player.Commands, - customLayout: ImmutableList, - showPauseButton: Boolean - ): ImmutableList { - val isPlaying = mediaSession?.player?.isPlaying == true - val playPauseButton = CommandButton.Builder() - .setDisplayName( - if (isPlaying) { - getString(R.string.media_player_pause) - } else { - getString(R.string.media_player_play) - } - ) - .setIconResId(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow) - .setPlayerCommand(COMMAND_PLAY_PAUSE) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 1) }) - .build() - - return ImmutableList.of(seekBackward, playPauseButton, seekForward) - } - } - - override fun onTaskRemoved(rootIntent: Intent?) { - release() - } - - override fun onDestroy() { - unregisterReceiver(stopReceiver) - serviceScope.cancel() - mediaSession?.run { - player.release() - release() - mediaSession = null - } - super.onDestroy() - } - - private fun release() { - val player = mediaSession?.player - if (player?.playWhenReady == true) { - player.pause() - } - val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - nm.cancel(DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession - - companion object { - private val TAG = BackgroundPlayerService::class.java.simpleName - - private const val SESSION_COMMAND_ACTION_SEEK_BACK = "SESSION_COMMAND_ACTION_SEEK_BACK" - private const val SESSION_COMMAND_ACTION_SEEK_FORWARD = "SESSION_COMMAND_ACTION_SEEK_FORWARD" - private const val BACKGROUND_MEDIA_SESSION_ID = - "com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID" - - const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION = - "com.nextcloud.client.media.RELEASE_MEDIA_SESSION" - const val STOP_MEDIA_SESSION_BROADCAST_ACTION = - "com.nextcloud.client.media.STOP_MEDIA_SESSION" - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt b/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt deleted file mode 100644 index 8b34266453a7..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/ErrorFormat.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import android.media.MediaPlayer -import androidx.media3.common.PlaybackException -import com.owncloud.android.R - -/** - * This code has been moved from legacy media player service. - */ -@Deprecated("This legacy helper should be refactored") -@Suppress("ComplexMethod") // it's legacy code -object ErrorFormat { - - /** Error code for specific messages - see regular error codes at [MediaPlayer] */ - const val OC_MEDIA_ERROR = 0 - - @JvmStatic - fun toString(context: Context?, what: Int, extra: Int): String { - val messageId: Int - - if (what == OC_MEDIA_ERROR) { - messageId = extra - } else if (extra == MediaPlayer.MEDIA_ERROR_UNSUPPORTED) { - /* Added in API level 17 - Bitstream is conforming to the related coding standard or file spec, - but the media framework does not support the feature. - Constant Value: -1010 (0xfffffc0e) - */ - messageId = R.string.media_err_unsupported - } else if (extra == MediaPlayer.MEDIA_ERROR_IO) { - /* Added in API level 17 - File or network related operation errors. - Constant Value: -1004 (0xfffffc14) - */ - messageId = R.string.media_err_io - } else if (extra == MediaPlayer.MEDIA_ERROR_MALFORMED) { - /* Added in API level 17 - Bitstream is not conforming to the related coding standard or file spec. - Constant Value: -1007 (0xfffffc11) - */ - messageId = R.string.media_err_malformed - } else if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) { - /* Added in API level 17 - Some operation takes too long to complete, usually more than 3-5 seconds. - Constant Value: -110 (0xffffff92) - */ - messageId = R.string.media_err_timeout - } else if (what == MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK) { - /* Added in API level 3 - The video is streamed and its container is not valid for progressive playback i.e the video's index - (e.g moov atom) is not at the start of the file. - Constant Value: 200 (0x000000c8) - */ - messageId = R.string.media_err_invalid_progressive_playback - } else { - /* MediaPlayer.MEDIA_ERROR_UNKNOWN - Added in API level 1 - Unspecified media player error. - Constant Value: 1 (0x00000001) - */ - /* MediaPlayer.MEDIA_ERROR_SERVER_DIED) - Added in API level 1 - Media server died. In this case, the application must release the MediaPlayer - object and instantiate a new one. - Constant Value: 100 (0x00000064) - */ - messageId = R.string.media_err_unknown - } - return context?.getString(messageId) ?: "Media error" - } - - fun toString(context: Context, exception: PlaybackException): String { - val messageId = when (exception.errorCode) { - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { - R.string.media_err_unsupported - } - - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, - PlaybackException.ERROR_CODE_IO_NO_PERMISSION, - PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE -> { - R.string.media_err_io - } - - PlaybackException.ERROR_CODE_TIMEOUT -> { - R.string.media_err_timeout - } - - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { - R.string.media_err_malformed - } - - else -> { - R.string.media_err_invalid_progressive_playback - } - } - return context.getString(messageId) - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt b/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt deleted file mode 100644 index 3b0dd56ef341..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/ExoplayerListener.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import android.content.DialogInterface -import android.view.View -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.owncloud.android.R -import com.owncloud.android.lib.common.utils.Log_OC - -class ExoplayerListener( - private val context: Context, - private val playerView: View, - private val exoPlayer: ExoPlayer, - private val onCompleted: () -> Unit = { } -) : Player.Listener { - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - if (playbackState == Player.STATE_ENDED) { - onCompletion() - onCompleted() - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - super.onIsPlayingChanged(isPlaying) - Log_OC.d(TAG, "Exoplayer keep screen on: $isPlaying") - playerView.keepScreenOn = isPlaying - } - - private fun onCompletion() { - exoPlayer.let { - it.seekToDefaultPosition() - it.pause() - } - } - - override fun onPlayerError(error: PlaybackException) { - super.onPlayerError(error) - Log_OC.e(TAG, "Exoplayer error", error) - val message = ErrorFormat.toString(context, error) - MaterialAlertDialogBuilder(context) - .setMessage(message) - .setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int -> - onCompletion() - } - .setCancelable(false) - .show() - } - - companion object { - private const val TAG = "ExoplayerListener" - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt b/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt deleted file mode 100644 index ea69556ae48d..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2018 Tobias Kaminsky - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.os.AsyncTask -import com.owncloud.android.files.StreamMediaFileOperation -import com.owncloud.android.lib.common.OwnCloudClient - -internal class LoadUrlTask( - private val client: OwnCloudClient, - private val fileId: Long, - private val onResult: (String?) -> Unit -) : AsyncTask() { - - override fun doInBackground(vararg args: Void): String? { - val operation = StreamMediaFileOperation(fileId) - val result = operation.execute(client) - return when (result.isSuccess) { - true -> result.data[0] as String - false -> null - } - } - - override fun onPostExecute(url: String?) { - if (!isCancelled) { - onResult(url) - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt b/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt deleted file mode 100644 index 283f4e56ba04..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/NextcloudExoPlayer.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import androidx.annotation.OptIn -import androidx.media3.common.AudioAttributes -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import com.nextcloud.common.NextcloudClient -import com.owncloud.android.MainApp - -object NextcloudExoPlayer { - private const val FIVE_SECONDS_IN_MILLIS = 5000L - - /** - * Creates an [ExoPlayer] that uses [NextcloudClient] for HTTP connections, thus respecting redirections, - * IP versions and certificates. - * - */ - @OptIn(UnstableApi::class) - @JvmStatic - fun createNextcloudExoplayer(context: Context, nextcloudClient: NextcloudClient): ExoPlayer { - val okHttpDataSourceFactory = OkHttpDataSource.Factory(nextcloudClient.client) - okHttpDataSourceFactory.setUserAgent(MainApp.getUserAgent()) - val mediaSourceFactory = DefaultMediaSourceFactory( - DefaultDataSource.Factory( - context, - okHttpDataSourceFactory - ) - ) - return ExoPlayer - .Builder(context) - .setMediaSourceFactory(mediaSourceFactory) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setSeekForwardIncrementMs(FIVE_SECONDS_IN_MILLIS) - .build() - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/Player.kt b/app/src/main/java/com/nextcloud/client/media/Player.kt deleted file mode 100644 index 0074690ea87d..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/Player.kt +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.Context -import android.media.AudioManager -import android.media.MediaPlayer -import android.os.PowerManager -import android.widget.MediaController -import com.nextcloud.client.account.User -import com.nextcloud.client.media.PlayerStateMachine.Event -import com.nextcloud.client.media.PlayerStateMachine.State -import com.nextcloud.client.network.ClientFactory -import com.owncloud.android.R -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.common.utils.Log_OC - -@Suppress("TooManyFunctions") -internal class Player( - private val context: Context, - private val clientFactory: ClientFactory, - private val listener: Listener? = null, - audioManager: AudioManager, - private val mediaPlayerCreator: () -> MediaPlayer = { MediaPlayer() } -) : MediaController.MediaPlayerControl { - - private companion object { - const val DEFAULT_VOLUME = 1.0f - const val DUCK_VOLUME = 0.1f - const val MIN_DURATION_ALLOWING_SEEK = 3000 - } - - interface Listener { - fun onRunning(file: OCFile) - fun onStart() - fun onPause() - fun onStop() - fun onError(error: PlayerError) - } - - private var stateMachine: PlayerStateMachine - private var loadUrlTask: LoadUrlTask? = null - - private var enqueuedFile: PlaylistItem? = null - - private var playedFile: OCFile? = null - private var startPositionMs: Long = 0 - private var autoPlay = true - private var user: User? = null - private var dataSource: String? = null - private var lastError: PlayerError? = null - private var mediaPlayer: MediaPlayer? = null - private val focusManager = AudioFocusManager(audioManager, this::onAudioFocusChange) - - private val delegate = object : PlayerStateMachine.Delegate { - override val isDownloaded: Boolean get() = playedFile?.isDown ?: false - override val isAutoplayEnabled: Boolean get() = autoPlay - override val hasEnqueuedFile: Boolean get() = enqueuedFile != null - - override fun onStartRunning() { - trace("onStartRunning()") - enqueuedFile.let { - if (it != null) { - playedFile = it.file - startPositionMs = it.startPositionMs - autoPlay = it.autoPlay - user = it.user - dataSource = if (it.file.isDown) it.file.storagePath else null - listener?.onRunning(it.file) - } else { - throw IllegalStateException("Player started without enqueued file.") - } - } - } - - override fun onStartDownloading() { - trace("onStartDownloading()") - checkNotNull(playedFile) { "File not set." } - checkNotNull(user) - playedFile?.let { - val client = clientFactory.create(user) - val task = LoadUrlTask(client, it.localId, this@Player::onDownloaded) - task.execute() - loadUrlTask = task - } - } - - override fun onPrepare() { - trace("onPrepare()") - mediaPlayer = mediaPlayerCreator.invoke() - mediaPlayer?.setOnErrorListener(this@Player::onMediaPlayerError) - mediaPlayer?.setOnPreparedListener(this@Player::onMediaPlayerPrepared) - mediaPlayer?.setOnCompletionListener(this@Player::onMediaPlayerCompleted) - mediaPlayer?.setOnBufferingUpdateListener(this@Player::onMediaPlayerBufferingUpdate) - mediaPlayer?.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK) - mediaPlayer?.setDataSource(dataSource) - mediaPlayer?.setAudioStreamType(AudioManager.STREAM_MUSIC) - mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) - mediaPlayer?.prepareAsync() - } - - override fun onStopped() { - trace("onStoppped()") - mediaPlayer?.stop() - mediaPlayer?.reset() - mediaPlayer?.release() - mediaPlayer = null - - playedFile = null - startPositionMs = 0 - user = null - autoPlay = true - dataSource = null - loadUrlTask?.cancel(true) - loadUrlTask = null - listener?.onStop() - } - - override fun onError() { - trace("onError()") - this.onStopped() - lastError?.let { - this@Player.listener?.onError(it) - } - if (lastError == null) { - this@Player.listener?.onError(PlayerError("Unknown")) - } - } - - override fun onStartPlayback() { - trace("onStartPlayback()") - mediaPlayer?.start() - listener?.onStart() - } - - override fun onPausePlayback() { - trace("onPausePlayback()") - if (mediaPlayer?.isPlaying == true) { - mediaPlayer?.pause() - listener?.onPause() - } - } - - override fun onRequestFocus() { - trace("onRequestFocus()") - focusManager.requestFocus() - } - - override fun onReleaseFocus() { - trace("onReleaseFocus()") - focusManager.releaseFocus() - } - - override fun onAudioDuck(enabled: Boolean) { - trace("onAudioDuck(): $enabled") - if (enabled) { - mediaPlayer?.setVolume(DUCK_VOLUME, DUCK_VOLUME) - } else { - mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) - } - } - } - - init { - stateMachine = PlayerStateMachine(delegate) - } - - fun play(item: PlaylistItem) { - if (item.file != playedFile) { - stateMachine.post(Event.STOP) - this.enqueuedFile = item - stateMachine.post(Event.PLAY) - } - } - - fun stop() { - stateMachine.post(Event.STOP) - } - - fun stop(file: OCFile) { - if (playedFile == file) { - stateMachine.post(Event.STOP) - } - } - - private fun onMediaPlayerError(mp: MediaPlayer, what: Int, extra: Int): Boolean { - lastError = PlayerError(ErrorFormat.toString(context, what, extra)) - stateMachine.post(Event.ERROR) - return true - } - - private fun onMediaPlayerPrepared(mp: MediaPlayer) { - trace("onMediaPlayerPrepared()") - stateMachine.post(Event.PREPARED) - } - - private fun onMediaPlayerCompleted(mp: MediaPlayer) { - stateMachine.post(Event.STOP) - } - - private fun onMediaPlayerBufferingUpdate(mp: MediaPlayer, percent: Int) { - trace("onMediaPlayerBufferingUpdate(): $percent") - } - - private fun onDownloaded(url: String?) { - if (url != null) { - dataSource = url - stateMachine.post(Event.DOWNLOADED) - } else { - lastError = PlayerError(context.getString(R.string.media_err_io)) - stateMachine.post(Event.ERROR) - } - } - - private fun onAudioFocusChange(focus: AudioFocus) { - when (focus) { - AudioFocus.FOCUS -> stateMachine.post(Event.FOCUS_GAIN) - AudioFocus.DUCK -> stateMachine.post(Event.FOCUS_DUCK) - AudioFocus.LOST -> stateMachine.post(Event.FOCUS_LOST) - } - } - - private fun trace(fmt: String, vararg args: Any?) { - Log_OC.v(javaClass.simpleName, fmt.format(args)) - } - - // region Media player controls - - override fun isPlaying(): Boolean = stateMachine.isInState(State.PLAYING) - - override fun canSeekForward(): Boolean = duration > MIN_DURATION_ALLOWING_SEEK - - override fun canSeekBackward(): Boolean = duration > MIN_DURATION_ALLOWING_SEEK - - override fun getDuration(): Int { - val hasDuration = setOf(State.PLAYING, State.PAUSED) - .find { stateMachine.isInState(it) } != null - return if (hasDuration) { - mediaPlayer?.duration ?: 0 - } else { - 0 - } - } - - override fun pause() { - stateMachine.post(Event.PAUSE) - } - - override fun getBufferPercentage(): Int = 0 - - override fun seekTo(pos: Int) { - if (stateMachine.isInState(State.PLAYING)) { - mediaPlayer?.seekTo(pos) - } - } - - override fun getCurrentPosition(): Int = mediaPlayer?.currentPosition ?: 0 - - override fun start() { - stateMachine.post(Event.PLAY) - } - - override fun getAudioSessionId(): Int = 0 - - override fun canPause(): Boolean = stateMachine.isInState(State.PLAYING) - - // endregion -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerError.kt b/app/src/main/java/com/nextcloud/client/media/PlayerError.kt deleted file mode 100644 index 85a380856527..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerError.kt +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -data class PlayerError(val message: String) diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerService.kt b/app/src/main/java/com/nextcloud/client/media/PlayerService.kt deleted file mode 100644 index 15e6398351c0..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerService.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.media.AudioManager -import android.os.Bundle -import android.os.IBinder -import android.widget.MediaController -import android.widget.Toast -import androidx.core.app.NotificationCompat -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.nextcloud.client.account.User -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.utils.ForegroundServiceHelper -import com.nextcloud.utils.extensions.getParcelableArgument -import com.owncloud.android.R -import com.owncloud.android.datamodel.ForegroundServiceType -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.notifications.NotificationUtils -import com.owncloud.android.ui.preview.PreviewMediaActivity -import com.owncloud.android.utils.theme.ViewThemeUtils -import dagger.android.AndroidInjection -import java.util.Locale -import javax.inject.Inject - -class PlayerService : Service() { - - companion object { - private const val TAG = "PlayerService" - - const val EXTRA_USER = "USER" - const val EXTRA_FILE = "FILE" - const val EXTRA_AUTO_PLAY = "EXTRA_AUTO_PLAY" - const val EXTRA_START_POSITION_MS = "START_POSITION_MS" - const val ACTION_PLAY = "PLAY" - const val ACTION_STOP = "STOP" - const val ACTION_TOGGLE = "TOGGLE" - const val ACTION_STOP_FILE = "STOP_FILE" - - const val IS_MEDIA_CONTROL_LAYOUT_READY = "IS_MEDIA_CONTROL_LAYOUT_READY" - } - - class Binder(val service: PlayerService) : android.os.Binder() { - - /** - * This property returns current instance of media player interface. - * It is not cached and it is suitable for polling. - */ - val player: MediaController.MediaPlayerControl get() = service.player - } - - private val playerListener = object : Player.Listener { - override fun onRunning(file: OCFile) { - Log_OC.d(TAG, "PlayerService.onRunning()") - val intent = Intent(PreviewMediaActivity.MEDIA_CONTROL_READY_RECEIVER).apply { - putExtra(IS_MEDIA_CONTROL_LAYOUT_READY, false) - } - LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) - } - - override fun onStart() { - Log_OC.d(TAG, "PlayerService.onStart()") - val intent = Intent(PreviewMediaActivity.MEDIA_CONTROL_READY_RECEIVER).apply { - putExtra(IS_MEDIA_CONTROL_LAYOUT_READY, true) - } - LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) - } - - override fun onPause() { - Log_OC.d(TAG, "PlayerService.onPause()") - } - - override fun onStop() { - Log_OC.d(TAG, "PlayerService.onStop()") - stopServiceAndRemoveNotification(null) - } - - override fun onError(error: PlayerError) { - Log_OC.d(TAG, "PlayerService.onError()") - Toast.makeText(this@PlayerService, error.message, Toast.LENGTH_SHORT).show() - } - } - - @Inject - lateinit var audioManager: AudioManager - - @Inject - lateinit var clientFactory: ClientFactory - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private lateinit var player: Player - private lateinit var notificationBuilder: NotificationCompat.Builder - private var isRunning = false - - override fun onCreate() { - super.onCreate() - - AndroidInjection.inject(this) - player = Player(applicationContext, clientFactory, playerListener, audioManager) - notificationBuilder = NotificationCompat.Builder(this) - viewThemeUtils.androidx.themeNotificationCompatBuilder(this, notificationBuilder) - - val stop = Intent(this, PlayerService::class.java).apply { - action = ACTION_STOP - } - - val pendingStop = PendingIntent.getService(this, 0, stop, PendingIntent.FLAG_IMMUTABLE) - notificationBuilder.addAction(0, getString(R.string.player_stop).lowercase(Locale.getDefault()), pendingStop) - - val toggle = Intent(this, PlayerService::class.java).apply { - action = ACTION_TOGGLE - } - - val pendingToggle = PendingIntent.getService(this, 0, toggle, PendingIntent.FLAG_IMMUTABLE) - notificationBuilder.addAction( - 0, - getString(R.string.player_toggle).lowercase(Locale.getDefault()), - pendingToggle - ) - } - - override fun onBind(intent: Intent?): IBinder? = Binder(this) - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - Log_OC.d(TAG, "player service started") - if (!isRunning) { - val file = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - if (file != null) { - startForeground(file) - } else { - startForegroundWithPlaceholder() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - return START_NOT_STICKY - } - } - - when (intent.action) { - ACTION_PLAY -> onActionPlay(intent) - ACTION_STOP -> onActionStop() - ACTION_STOP_FILE -> onActionStopFile(intent.extras) - ACTION_TOGGLE -> onActionToggle() - } - return START_NOT_STICKY - } - - private fun startForegroundWithPlaceholder() { - val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) - notificationBuilder.run { - setSmallIcon(R.drawable.ic_play_arrow) - setWhen(System.currentTimeMillis()) - setOngoing(false) - setContentTitle(ticker) - setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) - } - ForegroundServiceHelper.startService( - this, - R.string.media_notif_ticker, - notificationBuilder.build(), - ForegroundServiceType.MediaPlayback - ) - } - - private fun onActionToggle() { - player.run { - if (isPlaying) { - pause() - } else { - start() - } - } - } - - private fun onActionPlay(intent: Intent) { - val user: User = intent.getParcelableArgument(EXTRA_USER, User::class.java)!! - val file: OCFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java)!! - val startPos = intent.getLongExtra(EXTRA_START_POSITION_MS, 0) - val autoPlay = intent.getBooleanExtra(EXTRA_AUTO_PLAY, true) - val item = PlaylistItem(file = file, startPositionMs = startPos, autoPlay = autoPlay, user = user) - player.play(item) - } - - private fun onActionStop() { - stopServiceAndRemoveNotification(null) - } - - private fun onActionStopFile(args: Bundle?) { - val file: OCFile = args?.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - ?: throw IllegalArgumentException("Missing file argument") - stopServiceAndRemoveNotification(file) - } - - private fun startForeground(currentFile: OCFile) { - val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) - val content = getString(R.string.media_state_playing, currentFile.getFileName()) - - notificationBuilder.run { - setSmallIcon(R.drawable.ic_play_arrow) - setWhen(System.currentTimeMillis()) - setOngoing(true) - setContentTitle(ticker) - setContentText(content) - setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) - } - - ForegroundServiceHelper.startService( - this, - R.string.media_notif_ticker, - notificationBuilder.build(), - ForegroundServiceType.MediaPlayback - ) - - isRunning = true - } - - private fun stopServiceAndRemoveNotification(file: OCFile?) { - if (file == null) { - player.stop() - } else { - player.stop(file) - } - - if (isRunning) { - stopForeground(true) - stopSelf() - isRunning = false - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt b/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt deleted file mode 100644 index 2c89ca57dc62..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.widget.MediaController -import androidx.core.content.ContextCompat -import com.nextcloud.client.account.User -import com.owncloud.android.datamodel.OCFile - -@Suppress("TooManyFunctions") // implementing large interface -class PlayerServiceConnection(private val context: Context) : MediaController.MediaPlayerControl { - - var isConnected: Boolean = false - private set - - private var binder: PlayerService.Binder? = null - - fun bind() { - val intent = Intent(context, PlayerService::class.java) - context.bindService(intent, connection, Context.BIND_AUTO_CREATE) - } - - fun unbind() { - if (isConnected) { - binder = null - isConnected = false - context.unbindService(connection) - } - } - - fun start(user: User, file: OCFile, playImmediately: Boolean, position: Long) { - val i = Intent(context, PlayerService::class.java).apply { - putExtra(PlayerService.EXTRA_USER, user) - putExtra(PlayerService.EXTRA_FILE, file) - putExtra(PlayerService.EXTRA_AUTO_PLAY, playImmediately) - putExtra(PlayerService.EXTRA_START_POSITION_MS, position) - action = PlayerService.ACTION_PLAY - } - - startForegroundService(i) - } - - fun stop(file: OCFile) { - val i = Intent(context, PlayerService::class.java) - i.putExtra(PlayerService.EXTRA_FILE, file) - i.action = PlayerService.ACTION_STOP_FILE - try { - context.startService(i) - } catch (ex: IllegalStateException) { - // https://developer.android.com/about/versions/oreo/android-8.0-changes#back-all - // ignore it - the service is not running and does not need to be stopped - } - } - - fun stop() { - val i = Intent(context, PlayerService::class.java) - i.action = PlayerService.ACTION_STOP - try { - context.startService(i) - } catch (ex: IllegalStateException) { - // https://developer.android.com/about/versions/oreo/android-8.0-changes#back-all - // ignore it - the service is not running and does not need to be stopped - } - } - - private val connection = object : ServiceConnection { - override fun onServiceDisconnected(name: ComponentName?) { - isConnected = false - binder = null - } - - override fun onServiceConnected(name: ComponentName?, localBinder: IBinder?) { - binder = localBinder as PlayerService.Binder - isConnected = true - } - } - - // region Media controller - - override fun isPlaying(): Boolean = binder?.player?.isPlaying ?: false - - override fun canSeekForward(): Boolean = binder?.player?.canSeekForward() ?: false - - override fun getDuration(): Int = binder?.player?.duration ?: 0 - - override fun pause() { - binder?.player?.pause() - } - - override fun getBufferPercentage(): Int = binder?.player?.bufferPercentage ?: 0 - - override fun seekTo(pos: Int) { - binder?.player?.seekTo(pos) - } - - override fun getCurrentPosition(): Int = binder?.player?.currentPosition ?: 0 - - override fun canSeekBackward(): Boolean = binder?.player?.canSeekBackward() ?: false - - override fun start() { - binder?.player?.start() - } - - override fun getAudioSessionId(): Int = 0 - - override fun canPause(): Boolean = binder?.player?.canPause() ?: false - - // endregion - - private fun startForegroundService(i: Intent) { - ContextCompat.startForegroundService(context, i) - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt b/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt deleted file mode 100644 index 3310244ab455..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import com.github.oxo42.stateless4j.StateMachine -import com.github.oxo42.stateless4j.StateMachineConfig -import com.github.oxo42.stateless4j.delegates.Action -import com.github.oxo42.stateless4j.transitions.Transition -import java.util.ArrayDeque - -/** - * To see visual representation of the state machine, install PlanUml plugin. - * http://plantuml.com/ - * - * @startuml - * - * note "> - entry action\n< - exit action\n[exp] - transition guard\nfunction() - transition action" as README - * - * [*] --> STOPPED - * STOPPED --> RUNNING: PLAY\n[hasEnqueuedFile] - * RUNNING --> STOPPED: STOP\nonStop - * RUNNING --> STOPPED: ERROR\nonError - * RUNNING: >onStartRunning - * - * state RUNNING { - * [*] --> DOWNLOADING: [!isDownloaded] - * [*] --> PREPARING: [isDownloaded] - * DOWNLOADING: >onStartDownloading - * DOWNLOADING --> PREPARING: DOWNLOADED - * - * PREPARING: >onPrepare - * PREPARING --> PLAYING: PREPARED\n[autoPlay] - * PREPARING --> PAUSED: PREPARED\n[!autoPlay] - * PLAYING --> PAUSED: PAUSE\nFOCUS_LOST - * - * PAUSED: >onPausePlayback - * PAUSED --> PLAYING: PLAY - * - * PLAYING: >onRequestFocus - * PLAYING: AWAIT_FOCUS - * AWAIT_FOCUS --> FOCUSED: FOCUS_GAIN\nonStartPlayback() - * FOCUSED -l-> DUCKED: FOCUS_DUCK - * DUCKED: >onAudioDuck(true)\n FOCUSED: FOCUS_GAIN - * } - * } - * - * @enduml - */ -internal class PlayerStateMachine(initialState: State, private val delegate: Delegate) { - - constructor(delegate: Delegate) : this(State.STOPPED, delegate) - - interface Delegate { - val isDownloaded: Boolean - val isAutoplayEnabled: Boolean - val hasEnqueuedFile: Boolean - - fun onStartRunning() - fun onStartDownloading() - fun onPrepare() - fun onStopped() - fun onError() - fun onStartPlayback() - fun onPausePlayback() - fun onRequestFocus() - fun onReleaseFocus() - fun onAudioDuck(enabled: Boolean) - } - - enum class State { - STOPPED, - RUNNING, - RUNNING_INITIAL, - DOWNLOADING, - PREPARING, - PAUSED, - PLAYING, - AWAIT_FOCUS, - FOCUSED, - DUCKED - } - - enum class Event { - PLAY, - DOWNLOADED, - PREPARED, - STOP, - PAUSE, - ERROR, - FOCUS_LOST, - FOCUS_GAIN, - FOCUS_DUCK, - IMMEDIATE_TRANSITION - } - - private var pendingEvents = ArrayDeque() - private var isProcessing = false - private val stateMachine: StateMachine - - /** - * Immediate state machine state. This attribute provides innermost active state. - * For checking parent states, use [PlayerStateMachine.isInState]. - */ - val state: State - get() { - return stateMachine.state - } - - init { - val config = StateMachineConfig() - - config.configure(State.STOPPED) - .permitIf(Event.PLAY, State.RUNNING_INITIAL) { delegate.hasEnqueuedFile } - .onEntryFrom(Event.STOP, delegate::onStopped) - .onEntryFrom(Event.ERROR, delegate::onError) - - config.configure(State.RUNNING) - .permit(Event.STOP, State.STOPPED) - .permit(Event.ERROR, State.STOPPED) - .onEntry(delegate::onStartRunning) - - config.configure(State.RUNNING_INITIAL) - .substateOf(State.RUNNING) - .permitIf(Event.IMMEDIATE_TRANSITION, State.DOWNLOADING, { !delegate.isDownloaded }) - .permitIf(Event.IMMEDIATE_TRANSITION, State.PREPARING, { delegate.isDownloaded }) - .onEntry(this::immediateTransition) - - config.configure(State.DOWNLOADING) - .substateOf(State.RUNNING) - .permit(Event.DOWNLOADED, State.PREPARING) - .onEntry(delegate::onStartDownloading) - - config.configure(State.PREPARING) - .substateOf(State.RUNNING) - .permitIf(Event.PREPARED, State.AWAIT_FOCUS) { delegate.isAutoplayEnabled } - .permitIf(Event.PREPARED, State.PAUSED) { !delegate.isAutoplayEnabled } - .onEntry(delegate::onPrepare) - - config.configure(State.PLAYING) - .substateOf(State.RUNNING) - .permit(Event.PAUSE, State.PAUSED) - .permit(Event.FOCUS_LOST, State.PAUSED) - .onEntry(delegate::onRequestFocus) - .onExit(delegate::onReleaseFocus) - - config.configure(State.PAUSED) - .substateOf(State.RUNNING) - .permit(Event.PLAY, State.AWAIT_FOCUS) - .onEntry(delegate::onPausePlayback) - - config.configure(State.AWAIT_FOCUS) - .substateOf(State.PLAYING) - .permit(Event.FOCUS_GAIN, State.FOCUSED) - - config.configure(State.FOCUSED) - .substateOf(State.PLAYING) - .permit(Event.FOCUS_DUCK, State.DUCKED) - .onEntry(this::onAudioFocusGain) - - config.configure(State.DUCKED) - .substateOf(State.PLAYING) - .permit(Event.FOCUS_GAIN, State.FOCUSED) - .onEntry(Action { delegate.onAudioDuck(true) }) - .onExit(Action { delegate.onAudioDuck(false) }) - - stateMachine = StateMachine(initialState, config) - stateMachine.onUnhandledTrigger { _, _ -> - /* ignore unhandled event */ - } - } - - private fun immediateTransition() { - stateMachine.fire(Event.IMMEDIATE_TRANSITION) - } - - private fun onAudioFocusGain(t: Transition) { - if (t.source == State.AWAIT_FOCUS) { - delegate.onStartPlayback() - } - } - - /** - * Check if state machine is in a given state. - * Contrary to [PlayerStateMachine.state] attribute, this method checks for - * parent states. - */ - fun isInState(state: State): Boolean = stateMachine.isInState(state) - - /** - * Post state machine event to internal queue. - * - * This design ensures that we're not triggering multiple events - * from state machines callbacks before the transition is fully - * completed. - * - * Method is re-entrant. - */ - fun post(event: Event) { - pendingEvents.addLast(event) - if (!isProcessing) { - isProcessing = true - while (pendingEvents.isNotEmpty()) { - val processedEvent = pendingEvents.removeFirst() - stateMachine.fire(processedEvent) - } - isProcessing = false - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt b/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt deleted file mode 100644 index 2415145733ad..000000000000 --- a/app/src/main/java/com/nextcloud/client/media/PlaylistItem.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2021 Tobias Kaminsky - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import com.nextcloud.client.account.User -import com.owncloud.android.datamodel.OCFile - -data class PlaylistItem(val file: OCFile, val startPositionMs: Long, val autoPlay: Boolean, val user: User) diff --git a/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt new file mode 100644 index 000000000000..53cc8ec28e43 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/PlayerModule.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import com.nextcloud.client.player.media3.PlaybackService +import com.nextcloud.client.player.ui.PlayerActivity +import com.nextcloud.client.player.ui.PlayerProgressIndicator +import com.nextcloud.client.player.ui.audio.AudioFileFragment +import com.nextcloud.client.player.ui.audio.AudioPlayerView +import com.nextcloud.client.player.ui.control.PlayerControlView +import com.nextcloud.client.player.ui.video.VideoFileFragment +import com.nextcloud.client.player.ui.video.VideoPlayerView +import dagger.Module +import dagger.Provides +import dagger.android.ContributesAndroidInjector +import java.io.File +import javax.inject.Singleton + +private const val PLAYER_CACHE_DIR_NAME = "player" +private const val PLAYER_CACHE_SIZE = 300 * 1024 * 1024L + +@Module(includes = [PlayerModule.AndroidInjector::class]) +class PlayerModule { + + @Provides + @Singleton + @UnstableApi + fun provideCache(context: Context): Cache = SimpleCache( + File(context.cacheDir, PLAYER_CACHE_DIR_NAME), + LeastRecentlyUsedCacheEvictor(PLAYER_CACHE_SIZE) + ) + + @Module + abstract class AndroidInjector { + + @UnstableApi + @ContributesAndroidInjector + abstract fun playbackService(): PlaybackService + + @ContributesAndroidInjector + abstract fun playerActivity(): PlayerActivity + + @ContributesAndroidInjector + abstract fun audioPlayerView(): AudioPlayerView + + @ContributesAndroidInjector + abstract fun videoPlayerView(): VideoPlayerView + + @ContributesAndroidInjector + abstract fun playerControlView(): PlayerControlView + + @ContributesAndroidInjector + abstract fun playerProgressIndicator(): PlayerProgressIndicator + + @ContributesAndroidInjector + abstract fun audioFileFragment(): AudioFileFragment + + @ContributesAndroidInjector + abstract fun videoFileFragment(): VideoFileFragment + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt new file mode 100644 index 000000000000..8d2f7299bddf --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/MediaNotificationProvider.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3 + +import android.content.Context +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.DefaultMediaNotificationProvider +import com.nextcloud.client.player.media3.common.playbackFile + +@UnstableApi +class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { + + override fun getNotificationContentTitle(metadata: MediaMetadata): CharSequence? = + if (metadata.title.isNullOrEmpty()) { + metadata.playbackFile?.getNameWithoutExtension() + } else { + metadata.title + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt new file mode 100644 index 000000000000..cf7e3c5aa92d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModel.kt @@ -0,0 +1,260 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3 + +import android.content.ComponentName +import android.content.Context +import android.view.SurfaceView +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionToken +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.media3.common.toMediaItem +import com.nextcloud.client.player.media3.common.indexOfFirst +import com.nextcloud.client.player.media3.common.setRepeatMode +import com.nextcloud.client.player.media3.common.updateMediaItems +import com.nextcloud.client.player.media3.session.MediaSessionFactory +import com.nextcloud.client.player.model.PlaybackSettings +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.PlaybackFiles +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.util.PeriodicAction +import com.owncloud.android.datamodel.OCFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@OptIn(markerClass = [UnstableApi::class]) +@Suppress("TooManyFunctions") +class PlaybackModel @Inject constructor( + private val context: Context, + private val mediaSessionFactory: MediaSessionFactory, + private val playbackSettings: PlaybackSettings +) { + + companion object { + private const val CHECK_PROGRESS_INTERVAL = 1000L + } + + interface Listener { + + fun onPlaybackUpdate(state: PlaybackState) + + fun onPlaybackError(error: Throwable) { + // Default empty implementation + } + } + + private val listeners = mutableListOf() + + private val checkProgressPeriodicAction = PeriodicAction(CHECK_PROGRESS_INTERVAL) { + notifyPlaybackUpdate() + } + + private val playerListener = PlaybackModelPlayerListener( + checkProgressPeriodicAction, + this::notifyPlaybackUpdate, + this::onPlaybackError + ) + + private val controllerListener = object : MediaController.Listener { + override fun onDisconnected(controller: MediaController) { + controller.removeListener(playerListener) + controllerScope?.cancel() + checkProgressPeriodicAction.stop() + notifyPlaybackUpdate() + } + } + + private var controllerScope: CoroutineScope? = null + private var controller: Player? = null + + private var mediaSession: MediaSession? = null + + val state: PlaybackState? + get() = controller?.toPlaybackState() + + fun getMediaSession(): MediaSession = mediaSession ?: mediaSessionFactory.create().also { + mediaSession = it + } + + suspend fun start() { + val sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + controller = MediaController.Builder(context, sessionToken) + .setListener(controllerListener) + .buildAsync() + .await() + .apply { + addListener(playerListener) + setRepeatMode(playbackSettings.repeatMode) + shuffleModeEnabled = playbackSettings.isShuffle + controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + } + } + + fun setFilesFlow(filesFlow: Flow) { + controllerScope?.launch { + filesFlow + .catch { + notifyPlaybackError(it) + release() + } + .collectLatest { setFiles(it) } + } + } + + fun setFiles(files: PlaybackFiles) { + if (files.list.isEmpty()) { + release() + 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.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 } + + fun release() { + controller?.release() + mediaSession?.player?.release() + mediaSession?.release() + mediaSession = null + } + + fun setVideoSurfaceView(surfaceView: SurfaceView?) { + controller?.setVideoSurfaceView(surfaceView) + } + + fun addListener(listener: Listener) { + if (!listeners.contains(listener)) { + listeners.add(listener) + } + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + fun play() { + controller?.run { + prepare() + play() + } + } + + fun pause() { + controller?.pause() + } + + fun playNext() { + controller?.run { + seekToNextMediaItem() + prepare() + } + } + + fun playPrevious() { + controller?.run { + seekToPreviousMediaItem() + prepare() + } + } + + fun seekToPosition(positionInMilliseconds: Long) { + controller?.seekTo(positionInMilliseconds) + } + + fun setRepeatMode(repeatMode: RepeatMode) { + playbackSettings.setRepeatMode(repeatMode) + controller?.setRepeatMode(repeatMode) + } + + fun setShuffle(shuffle: Boolean) { + playbackSettings.setShuffle(shuffle) + controller?.shuffleModeEnabled = shuffle + } + + fun switchToFile(file: PlaybackFile) { + controller?.run { + val mediaItemIndex = indexOfFirst { it.mediaId == file.id } + if (mediaItemIndex >= 0 && mediaItemIndex != currentMediaItemIndex) { + seekToDefaultPosition(mediaItemIndex) + prepare() + } + } + } + + fun stopPlaying(file: OCFile) { + controller?.run { + val mediaItemIndex = indexOfFirst { it.mediaId == file.localId.toString() } + if (mediaItemIndex >= 0) { + release() + } + } + } + + private fun notifyPlaybackUpdate() { + val currentState = state ?: return + for (i in 0 until listeners.size) { + listeners.getOrNull(i)?.onPlaybackUpdate(currentState) + } + } + + private fun notifyPlaybackError(error: Throwable) { + for (i in 0 until listeners.size) { + listeners.getOrNull(i)?.onPlaybackError(error) + } + } + + private fun onPlaybackError(error: Throwable) { + notifyPlaybackError(error) + state?.let { + if (shouldSwitchToNextSource(it)) { + playNext() + } + } + } + + private fun shouldSwitchToNextSource(state: PlaybackState): Boolean { + val currentFile = state.currentItemState?.file + val currentFiles = state.currentFiles + val oneFileInQueue = currentFiles.size == 1 + val endOfQueue = currentFiles.indexOf(currentFile) == currentFiles.lastIndex + return !oneFileInQueue && !endOfQueue + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt new file mode 100644 index 000000000000..9c7ee00b7218 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackModelPlayerListener.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3 + +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.exoplayer.ExoPlaybackException +import androidx.media3.exoplayer.source.UnrecognizedInputFormatException +import com.nextcloud.client.player.model.error.SourceException +import com.nextcloud.client.player.util.PeriodicAction + +class PlaybackModelPlayerListener( + private val checkProgressPeriodicAction: PeriodicAction, + private val onPlaybackUpdate: () -> Unit, + private val onPlaybackError: (Throwable) -> Unit +) : Player.Listener { + + companion object { + private const val BROKEN_SOURCE_ERROR_CODE: Int = 416 + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + onPlaybackUpdate() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + onPlaybackUpdate() + } + + override fun onTracksChanged(tracks: Tracks) { + onPlaybackUpdate() + } + + override fun onPlaybackStateChanged(playbackState: Int) { + onPlaybackUpdate() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + onPlaybackUpdate() + if (isPlaying) { + checkProgressPeriodicAction.start() + } else { + checkProgressPeriodicAction.stop() + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + onPlaybackUpdate() + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + onPlaybackUpdate() + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + onPlaybackUpdate() + } + + @UnstableApi + override fun onPlayerError(error: PlaybackException) { + if (error is ExoPlaybackException && error.type == ExoPlaybackException.TYPE_SOURCE) { + onPlaybackError(error.toSourceException()) + } else { + onPlaybackError(error) + } + } + + @UnstableApi + private fun ExoPlaybackException.toSourceException(): SourceException = + if (sourceException is InvalidResponseCodeException) { + SourceException((sourceException as InvalidResponseCodeException).responseCode) + } else if (cause != null && cause is UnrecognizedInputFormatException) { + SourceException(BROKEN_SOURCE_ERROR_CODE) + } else { + SourceException() + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt new file mode 100644 index 000000000000..99fe41b8ab0f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackService.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3 + +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ControllerInfo +import androidx.media3.session.MediaSessionService +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.nextcloud.client.player.ui.PlayerActivity +import dagger.android.AndroidInjection +import javax.inject.Inject + +@UnstableApi +class PlaybackService : MediaSessionService() { + + @Inject + lateinit var playbackModel: PlaybackModel + + private var bindingCount: Int = 0 + + override fun onCreate() { + super.onCreate() + AndroidInjection.inject(this) + setMediaNotificationProvider(MediaNotificationProvider(this)) + } + + override fun onGetSession(controllerInfo: ControllerInfo): MediaSession? = playbackModel.getMediaSession() + + override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { + createSessionActivity(session.player.currentMediaItem)?.let(session::setSessionActivity) + super.onUpdateNotification(session, startInForegroundRequired) + } + + override fun onBind(intent: Intent?): IBinder? { + val result = super.onBind(intent) + if (result != null) { + bindingCount++ + } + return result + } + + override fun onUnbind(intent: Intent?): Boolean { + bindingCount-- + if (bindingCount == 0) { + stopSelf() + } + return super.onUnbind(intent) + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + playbackModel.release() + stopSelf() + } + + override fun onDestroy() { + playbackModel.release() + super.onDestroy() + } + + private fun createSessionActivity(currentMediaItem: MediaItem?): PendingIntent? { + val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null + val fileType = PlaybackFileType.entries + .firstOrNull { currentFile.mimeType.startsWith(it.value, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported file type: ${currentFile.mimeType}") + + val intent = PlayerActivity.createIntent(this, fileType) + val requestCode = System.currentTimeMillis().toInt() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.getActivity(this, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt new file mode 100644 index 000000000000..08f49811c550 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/PlaybackStateMapper.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3 + +import androidx.media3.common.Player +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemMetadata +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import com.nextcloud.client.player.model.state.VideoSize + +fun Player.toPlaybackState(): PlaybackState = PlaybackState( + currentFiles = getCurrentFiles(), + currentItemState = getCurrentItemState(), + repeatMode = mapRepeatMode(), + shuffle = shuffleModeEnabled +) + +private fun Player.getCurrentFiles(): List = buildList { + for (i in 0 until mediaItemCount) { + val mediaItem = getMediaItemAt(i) + val playbackFile = mediaItem.mediaMetadata.playbackFile + playbackFile?.let(::add) + } +} + +private fun Player.getCurrentItemState(): PlaybackItemState? { + val currentFile = currentMediaItem?.mediaMetadata?.playbackFile ?: return null + return PlaybackItemState( + file = currentFile, + playerState = mapPlayerState(), + metadata = if (mediaMetadata.playbackFile?.id == currentFile.id) mapMetadata(currentFile) else null, + videoSize = mapVideoSize(), + currentTimeInMilliseconds = currentPosition, + maxTimeInMilliseconds = duration + ) +} + +private fun Player.mapPlayerState(): PlayerState = when (playbackState) { + Player.STATE_IDLE -> PlayerState.IDLE + Player.STATE_ENDED -> PlayerState.COMPLETED + Player.STATE_BUFFERING, Player.STATE_READY -> if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + else -> PlayerState.NONE +} + +private fun Player.mapMetadata(currentFile: PlaybackFile) = PlaybackItemMetadata( + title = mediaMetadata.title?.takeIf { it.isNotEmpty() } ?: currentFile.getNameWithoutExtension(), + artist = mediaMetadata.artist, + album = mediaMetadata.albumTitle, + genre = mediaMetadata.genre, + year = mediaMetadata.recordingYear, + description = mediaMetadata.description, + artworkData = mediaMetadata.artworkData, + artworkUri = mediaMetadata.artworkUri?.toString() +) + +private fun Player.mapVideoSize(): VideoSize? = videoSize + .takeIf { it.width > 0 && it.height > 0 } + ?.let { VideoSize(width = it.width, height = it.height) } + +private fun Player.mapRepeatMode(): RepeatMode = when (repeatMode) { + Player.REPEAT_MODE_ONE -> RepeatMode.SINGLE + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> RepeatMode.OFF +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt new file mode 100644 index 000000000000..9196991d9809 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaItem.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.common + +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import com.nextcloud.client.player.model.file.PlaybackFile + +fun PlaybackFile.toMediaItem(): MediaItem = MediaItem + .Builder() + .setMediaId(id) + .setUri(uri) + .setMediaMetadata(MediaMetadata.Builder().setExtras(this).build()) + .setMimeType(mimeType) + .build() diff --git a/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt new file mode 100644 index 000000000000..92011c2d83c7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/MediaMetadata.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: 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 new file mode 100644 index 000000000000..4680242237b2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/common/Player.kt @@ -0,0 +1,60 @@ +/* + * 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 new file mode 100644 index 000000000000..95f74088c8b5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSource.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.datasource + +import android.net.Uri +import androidx.core.net.toUri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import com.nextcloud.client.player.model.file.getRemoteFileId +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.files.StreamMediaFileOperation +import com.owncloud.android.lib.common.OwnCloudClient +import java.io.IOException + +@UnstableApi +class PlaybackDataSource( + private val delegate: DataSource, + private val fileDataStorageManager: FileDataStorageManager, + private val ownCloudClient: OwnCloudClient +) : DataSource by delegate { + + override fun getResponseHeaders() = delegate.responseHeaders + + override fun open(dataSpec: DataSpec): Long { + val fileId = dataSpec.uri.getRemoteFileId() ?: return delegate.open(dataSpec) + val file = fileDataStorageManager.getFileByLocalId(fileId) + return if (file != null && file.isDown) { + openStoredFile(dataSpec, file) + } else { + openRemoteFile(dataSpec, fileId) + } + } + + private fun openStoredFile(dataSpec: DataSpec, file: OCFile): Long { + val uri = file.storageUri + return delegate.open(dataSpec.buildUpon(uri)) + } + + private fun openRemoteFile(dataSpec: DataSpec, fileId: Long): Long { + val result = StreamMediaFileOperation(fileId).execute(ownCloudClient) + return if (result.isSuccess) { + val uri = (result.data[0] as? String)?.toUri() + ?: throw IllegalStateException("url is not valid, cannot stream") + delegate.open(dataSpec.buildUpon(uri)) + } else { + throw IOException("Failed to retrieve streaming uri", result.exception) + } + } + + private fun DataSpec.buildUpon(uri: Uri) = buildUpon().setUri(uri).build() +} 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 new file mode 100644 index 000000000000..40c0af28912f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/datasource/PlaybackDataSourceFactory.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.datasource + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.HttpDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import javax.inject.Inject + +@UnstableApi +class PlaybackDataSourceFactory @Inject constructor( + private val context: Context, + private val cache: Cache, + private val fileDataStorageManager: FileDataStorageManager, + private val clientFactory: ClientFactory, + private val accountManager: UserAccountManager +) : DataSource.Factory { + + override fun createDataSource(): DataSource = CacheDataSource.Factory() + .setUpstreamDataSourceFactory(createUpstreamDataSourceFactory()) + .setCache(cache) + .createDataSource() + + private fun createUpstreamDataSourceFactory() = DataSource.Factory { + PlaybackDataSource( + delegate = DefaultDataSource.Factory(context, createHttpDataSourceFactory()).createDataSource(), + fileDataStorageManager = fileDataStorageManager, + ownCloudClient = clientFactory.create(accountManager.user) + ) + } + + private fun createHttpDataSourceFactory(): HttpDataSource.Factory { + val client = clientFactory.createNextcloudClient(accountManager.user).client + return OkHttpDataSource.Factory(client) + .setUserAgent(MainApp.getUserAgent()) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt new file mode 100644 index 000000000000..6c6fb1feb28b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfig.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.resumption + +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.owncloud.android.ui.fragment.SearchType + +data class PlaybackResumptionConfig( + val currentFileId: String, + val folderId: Long, + val fileType: PlaybackFileType, + val searchType: SearchType? +) diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt new file mode 100644 index 000000000000..e6f05124c57f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionConfigStore.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.resumption + +import android.content.Context +import androidx.core.content.edit +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.owncloud.android.ui.fragment.SearchType +import javax.inject.Inject + +class PlaybackResumptionConfigStore @Inject constructor(private val context: Context) { + companion object { + private const val PREFERENCES_FILE_NAME = "playback_resumption_config" + private const val CURRENT_FILE_ID_KEY = "current_file_id" + private const val FOLDER_ID_KEY = "folder_id" + private const val FILE_TYPE_KEY = "file_type" + private const val SEARCH_TYPE_KEY = "search_type" + } + + private val preferences by lazy { + context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + } + + fun loadConfig(): PlaybackResumptionConfig? { + val currentFileId = preferences.getString(CURRENT_FILE_ID_KEY, null) + val folderId = preferences.getLong(FOLDER_ID_KEY, 0L) + val fileType = preferences.getString(FILE_TYPE_KEY, null)?.let(::playbackFileType) + val searchType = preferences.getString(SEARCH_TYPE_KEY, null)?.let(::searchType) + return if (currentFileId != null && folderId != 0L && fileType != null) { + PlaybackResumptionConfig(currentFileId, folderId, fileType, searchType) + } else { + null + } + } + + fun saveConfig(currentFileId: String, folderId: Long, fileType: PlaybackFileType, searchType: SearchType?) { + preferences.edit { + putString(CURRENT_FILE_ID_KEY, currentFileId) + putLong(FOLDER_ID_KEY, folderId) + putString(FILE_TYPE_KEY, fileType.value) + putString(SEARCH_TYPE_KEY, searchType?.name) + } + } + + fun updateCurrentFileId(currentFileId: String) { + preferences.edit { + putString(CURRENT_FILE_ID_KEY, currentFileId) + } + } + + private fun playbackFileType(value: String): PlaybackFileType? = PlaybackFileType.entries.firstOrNull { + it.value == value + } + + private fun searchType(name: String): SearchType? = SearchType.entries.firstOrNull { it.name == name } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt new file mode 100644 index 000000000000..b3a6d281411b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/resumption/PlaybackResumptionLauncher.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.resumption + +import androidx.media3.common.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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.util.concurrent.CancellationException +import javax.inject.Inject + +@UnstableApi +class PlaybackResumptionLauncher @Inject constructor( + private val playbackResumptionConfigStore: PlaybackResumptionConfigStore, + private val playbackFilesRepository: PlaybackFilesRepository, + private val playbackModel: PlaybackModel +) { + + suspend fun launch(): MediaItemsWithStartPosition = runCatching { + val (currentFileId, folderId, fileType, searchType) = playbackResumptionConfigStore.loadConfig() + ?: throw IllegalStateException("Playback resumption config is null") + val playbackFilesFlow = playbackFilesRepository.observe(folderId, fileType, searchType) + val playbackFiles = playbackFilesFlow.first().list.ifEmpty { + throw IllegalStateException("Playback files are empty") + } + withContext(Dispatchers.Main) { + playbackModel.start() + playbackModel.setFilesFlow(playbackFilesFlow.drop(1)) + } + playbackFiles.toMediaItemsWithStartPosition(currentFileId) + }.getOrElse { + if (it is CancellationException) throw it + val stubPlaybackFile = getStubPlaybackFile() + val stubPlaybackFiles = listOf(stubPlaybackFile) + withContext(Dispatchers.Main) { + playbackModel.start() + } + stubPlaybackFiles.toMediaItemsWithStartPosition(stubPlaybackFile.id) + } + + private fun List.toMediaItemsWithStartPosition(currentFileId: String) = MediaItemsWithStartPosition( + map { it.toMediaItem() }, + indexOfFirst { it.id == currentFileId }, + 0 + ) + + /** + * Workaround to avoid internal media3 crash + */ + private fun getStubPlaybackFile() = PlaybackFile( + id = "0", + uri = getPlaybackUri(0L).toString(), + name = "", + mimeType = "audio/mpeg", + contentLength = 0L, + lastModified = 0L, + isFavorite = false + ) +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt new file mode 100644 index 000000000000..c4a5c466a19a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionBitmapLoader.kt @@ -0,0 +1,117 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.session + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.BitmapLoader +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSourceBitmapLoader +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.ListeningExecutorService +import com.google.common.util.concurrent.MoreExecutors +import com.nextcloud.client.player.media3.common.playbackFile +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.owncloud.android.R +import com.owncloud.android.utils.MimeTypeUtil +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import javax.inject.Inject + +@UnstableApi +class MediaSessionBitmapLoader @Inject constructor( + private val context: Context, + private val thumbnailLoader: ThumbnailLoader +) : BitmapLoader by DataSourceBitmapLoader(context) { + + companion object { + private const val THUMBNAIL_TARGET_SIZE = 160 + private const val LARGE_THUMBNAIL_TARGET_SIZE = 320 + } + + private val thumbnailSize: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + LARGE_THUMBNAIL_TARGET_SIZE + } else { + THUMBNAIL_TARGET_SIZE + } + + private val executorService: ListeningExecutorService by lazy { + MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()) + } + + private var currentBitmapRequest: BitmapRequest? = null + + override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { + val file = metadata.playbackFile + val previousRequest = this.currentBitmapRequest + + if (previousRequest != null && previousRequest.isSameRequest(file, metadata)) { + return previousRequest.bitmapFuture + } + + val bitmapFuture = executorService.submit( + Callable { + getBitmapFromMetadata(metadata, file?.id) ?: run { + file?.let(::getBitmapForFile) ?: getDefaultBitmap(file) + } + } + ) + + this.currentBitmapRequest = BitmapRequest( + file?.id, + metadata.artworkData, + metadata.artworkUri, + bitmapFuture + ) + + return bitmapFuture + } + + private fun getBitmapFromMetadata(metadata: MediaMetadata, fileId: String?): Bitmap? { + val model = metadata.artworkData ?: metadata.artworkUri ?: return null + return runCatching { + thumbnailLoader.load(context, model, fileId, thumbnailSize, thumbnailSize).get() + }.getOrElse { + null + } + } + + private fun getBitmapForFile(file: PlaybackFile): Bitmap? = runCatching { + thumbnailLoader.load(context, file, thumbnailSize, thumbnailSize).get() + }.getOrElse { + null + } + + private fun getDefaultBitmap(file: PlaybackFile?): Bitmap { + val drawable = if (file != null && MimeTypeUtil.isVideo(file.mimeType)) { + ContextCompat.getDrawable(context, R.drawable.player_ic_notification_video) + } else { + ContextCompat.getDrawable(context, R.drawable.player_ic_notification_audio) + } + return drawable?.toBitmap() ?: throw IllegalStateException("Could not decode resource") + } + + private class BitmapRequest( + val mediaId: String?, + val artworkData: ByteArray?, + val artworkUri: Uri?, + val bitmapFuture: ListenableFuture + ) { + + fun isSameRequest(file: PlaybackFile?, metadata: MediaMetadata): Boolean = mediaId == file?.id && + artworkData.contentEquals(metadata.artworkData) && + artworkUri == metadata.artworkUri + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt new file mode 100644 index 000000000000..cd94c01708f4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionCallback.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.session + +import android.os.Bundle +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionLauncher +import com.nextcloud.client.player.media3.PlaybackModel +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.guava.future +import javax.inject.Inject +import javax.inject.Provider + +@UnstableApi +class MediaSessionCallback @Inject constructor( + private val playbackModelProvider: Provider, + private val playbackResumptionLauncherProvider: Provider +) : MediaSession.Callback { + private val playbackModel get() = playbackModelProvider.get() + private val playbackResumptionLauncher get() = playbackResumptionLauncherProvider.get() + + companion object { + const val CLOSE_ACTION = "CLOSE_ACTION" + } + + /** + * The result of [super.onConnect] carries no available player commands since media3 1.11.0, so the connection has + * to be built from [ConnectionResult.AcceptedResultBuilder] instead. Forwarding the commands of the super result + * leaves the controller unable to change media items, prepare or play. + */ + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): ConnectionResult { + val sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(SessionCommand(CLOSE_ACTION, Bundle.EMPTY)) + .build() + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + if (customCommand.customAction == CLOSE_ACTION) { + playbackModel.release() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture = GlobalScope.future { playbackResumptionLauncher.launch() } +} diff --git a/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt new file mode 100644 index 000000000000..8892293e50aa --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/media3/session/MediaSessionFactory.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.media3.session + +import android.content.Context +import android.os.Bundle +import androidx.media3.common.AudioAttributes +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import com.nextcloud.client.player.media3.datasource.PlaybackDataSourceFactory +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore +import com.owncloud.android.R +import javax.inject.Inject + +private const val SEEK_FORWARD_INCREMENT_IN_MILLISECONDS = 5000L + +@UnstableApi +class MediaSessionFactory @Inject constructor( + private val context: Context, + private val dataSourceFactory: PlaybackDataSourceFactory, + private val sessionCallback: MediaSessionCallback, + private val resumptionConfigStore: PlaybackResumptionConfigStore, + private val bitmapLoader: MediaSessionBitmapLoader +) { + + private val resumptionPlayerListener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + mediaItem?.let { resumptionConfigStore.updateCurrentFileId(it.mediaId) } + } + } + + fun create(): MediaSession { + val player = createPlayer() + player.addListener(resumptionPlayerListener) + return MediaSession + .Builder(context, player) + .setBitmapLoader(bitmapLoader) + .setCallback(sessionCallback) + .setCustomLayout(createCustomLayout()) + .build() + } + + private fun createPlayer(): Player = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setSeekForwardIncrementMs(SEEK_FORWARD_INCREMENT_IN_MILLISECONDS) + .build() + + private fun createCustomLayout(): List = listOf( + CommandButton + .Builder() + .setDisplayName(context.getString(R.string.player_media_controls_close_action_title)) + .setIconResId(R.drawable.player_ic_close) + .setSessionCommand(SessionCommand(MediaSessionCallback.CLOSE_ACTION, Bundle.EMPTY)) + .build() + ) +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt new file mode 100644 index 000000000000..9b8c7a97a2c3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/PlaybackSettings.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model + +import android.content.Context +import androidx.core.content.edit +import com.nextcloud.client.player.model.state.RepeatMode +import javax.inject.Inject + +class PlaybackSettings @Inject constructor(context: Context) { + companion object { + private const val PREFERENCES_FILE_NAME = "playback_settings" + private const val REPEAT_MODE_ID_KEY = "repeat_mode_id" + private const val SHUFFLE_KEY = "shuffle" + private val DEFAULT_REPEAT_MODE = RepeatMode.ALL + } + + private val preferences = context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + + val repeatMode: RepeatMode + get() = preferences.getInt(REPEAT_MODE_ID_KEY, -1) + .let { id -> RepeatMode.entries.firstOrNull { it.id == id } } + ?: DEFAULT_REPEAT_MODE + + val isShuffle: Boolean + get() = preferences.getBoolean(SHUFFLE_KEY, false) + + fun setRepeatMode(repeatMode: RepeatMode) { + preferences.edit { + putInt(REPEAT_MODE_ID_KEY, repeatMode.id) + } + } + + fun setShuffle(shuffle: Boolean) { + preferences.edit { + putBoolean(SHUFFLE_KEY, shuffle) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt new file mode 100644 index 000000000000..74b2ebeb0c23 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/ThumbnailLoader.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model + +import android.content.Context +import android.graphics.Bitmap +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.LazyHeaders +import com.bumptech.glide.signature.ObjectKey +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.player.model.file.PlaybackFile +import com.owncloud.android.MainApp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.concurrent.Future +import javax.inject.Inject +import kotlin.coroutines.resume + +class ThumbnailLoader @Inject constructor(clientFactory: ClientFactory, userAccountManager: UserAccountManager) { + private val client by lazy { clientFactory.createNextcloudClient(userAccountManager.user) } + + suspend fun await(context: Context, file: PlaybackFile, width: Int, height: Int): Bitmap? = + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + runCatching { + val future = load(context, file, width, height) + continuation.invokeOnCancellation { future.cancel(true) } + continuation.resume(future.get()) + }.onFailure { + if (it is CancellationException) throw it + continuation.resume(null) + } + } + } + + fun load(context: Context, file: PlaybackFile, width: Int, height: Int): Future { + val url = createUrl(file, width, height) + return load(context, url, file.id, width, height) + } + + fun load(context: Context, model: Any, fileId: String?, width: Int, height: Int): Future = Glide + .with(context) + .asBitmap() + .load(model) + .signature(ObjectKey(fileId ?: model.toString())) + .submit(width, height) + + fun load(imageView: ImageView, model: Any, fileId: String) { + Glide + .with(imageView) + .load(model) + .signature(ObjectKey(fileId)) + .into(imageView) + } + + private fun createUrl(file: PlaybackFile, width: Int, height: Int) = GlideUrl( + "${client.baseUri}/index.php/core/preview?fileId=${file.id}&x=$width&y=$height&a=1&mode=cover&forceIcon=0", + LazyHeaders.Builder() + .addHeader("Authorization", client.credentials) + .addHeader("User-Agent", MainApp.getUserAgent()) + .build() + ) +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt b/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt new file mode 100644 index 000000000000..9e13b74fc778 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/error/SourceException.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.error + +class SourceException(errorCode: Int = 0) : + Exception( + "Source not found. Error code: $errorCode" + ) diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt new file mode 100644 index 000000000000..682f085b181a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFile.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.file + +import java.io.Serializable + +data class PlaybackFile( + val id: String, + val uri: String, + val name: String, + val mimeType: String, + val contentLength: Long, + val lastModified: Long, + val isFavorite: Boolean +) : Serializable { + fun getNameWithoutExtension(): String = name.substringBeforeLast(".") +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt new file mode 100644 index 000000000000..9993f89172c3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileMapper.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: 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/PlaybackFileType.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt new file mode 100644 index 000000000000..3914ce80f0ab --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileType.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.file + +enum class PlaybackFileType(val value: String) { + AUDIO("audio"), + VIDEO("video"); + + companion object { + fun ofMimeType(mimeType: String): PlaybackFileType = entries + .firstOrNull { mimeType.startsWith(it.value, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported file type: $mimeType") + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt new file mode 100644 index 000000000000..562a2e3a2d53 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFileUriMapper.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: 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/PlaybackFiles.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt new file mode 100644 index 000000000000..839dc8e68131 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFiles.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.file + +data class PlaybackFiles(val list: List, val comparator: PlaybackFilesComparator) diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt new file mode 100644 index 000000000000..4465abb8a212 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesComparator.kt @@ -0,0 +1,46 @@ +/* + * 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.utils.FileSortOrder +import com.owncloud.android.utils.sort.AlphanumericComparator + +sealed interface PlaybackFilesComparator : Comparator { + + object NONE : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = 0 + } + + object FAVORITE : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = AlphanumericComparator.compare(a.name, b.name) + } + + object GALLERY : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified } + } + + object SHARED : PlaybackFilesComparator { + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = compareValuesBy(b, a) { it.lastModified } + } + + data class Folder(val sortType: FileSortOrder.SortType, val isAscending: Boolean) : PlaybackFilesComparator { + private val sortTypeComparator: Comparator = when (sortType) { + FileSortOrder.SortType.ALPHABET -> Comparator { a, b -> AlphanumericComparator.compare(a.name, b.name) } + FileSortOrder.SortType.SIZE -> compareBy { it.contentLength } + FileSortOrder.SortType.DATE -> compareBy { it.lastModified } + } + + private val delegate = compareByDescending(PlaybackFile::isFavorite) + .thenComparing(if (isAscending) sortTypeComparator else sortTypeComparator.reversed()) + + override fun compare(a: PlaybackFile, b: PlaybackFile): Int = delegate.compare(a, b) + } +} + +fun FileSortOrder.toPlaybackFilesComparator(): PlaybackFilesComparator = + PlaybackFilesComparator.Folder(getType(), isAscending) diff --git a/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt new file mode 100644 index 000000000000..e94a9d7e3be7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/file/PlaybackFilesRepository.kt @@ -0,0 +1,114 @@ +/* + * 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.content.ContentUris +import android.content.Context +import android.net.Uri +import com.nextcloud.client.player.util.observeContentChanges +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.ui.fragment.SearchType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import javax.inject.Inject + +private const val FETCH_DATA_DEBOUNCE_MS = 250L + +class PlaybackFilesRepository @Inject constructor( + private val context: Context, + private val storageManager: FileDataStorageManager, + 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()) + } + + private fun observeFavoritePlaybackFiles(fileType: PlaybackFileType): Flow = + observeData(ProviderTableMeta.CONTENT_URI, true) { + withContext(Dispatchers.IO) { + storageManager.favoriteFiles + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(PlaybackFilesComparator.FAVORITE) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.FAVORITE) } + } + } + + private fun observeGalleryPlaybackFiles(fileType: PlaybackFileType): Flow = + observeData(ProviderTableMeta.CONTENT_URI, true) { + withContext(Dispatchers.IO) { + storageManager.allGalleryItems + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(PlaybackFilesComparator.GALLERY) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.GALLERY) } + } + } + + private fun observeSharedPlaybackFiles(fileType: PlaybackFileType): Flow = + observeData(ProviderTableMeta.CONTENT_URI_SHARE, false) { + withContext(Dispatchers.IO) { + storageManager.shares + .asSequence() + .distinctBy { it.fileSource } + .map { it.toPlaybackFile() } + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .sortedWith(PlaybackFilesComparator.SHARED) + .let { PlaybackFiles(it.toList(), PlaybackFilesComparator.SHARED) } + } + } + + private fun observeFolderPlaybackFiles( + folderId: Long, + fileType: PlaybackFileType, + onDeviceOnly: Boolean + ): Flow = flow { + val uri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, folderId) + val comparator = withContext(Dispatchers.IO) { + preferences.getSortOrderByFolder(getFolder(folderId)).toPlaybackFilesComparator() + } + val playbackFiles = observeData(uri, false) { + withContext(Dispatchers.IO) { + storageManager.getFolderContent(getFolder(folderId), onDeviceOnly) + .asSequence() + .filter { it.mimeType.startsWith(fileType.value, ignoreCase = true) } + .map { it.toPlaybackFile() } + .sortedWith(comparator) + .let { PlaybackFiles(it.toList(), comparator) } + } + } + emitAll(playbackFiles) + } + + private fun getFolder(folderId: Long) = storageManager.getFileById(folderId) + ?: throw IllegalStateException("Folder not found") + + private fun observeData(uri: Uri, notifyForDescendants: Boolean, fetchData: suspend () -> T): Flow = + context.contentResolver.observeContentChanges(uri, notifyForDescendants) + .debounce(FETCH_DATA_DEBOUNCE_MS) // Debounce to avoid too frequent data fetching for batch updates + .map { fetchData() } + .onStart { emit(fetchData()) } + .distinctUntilChanged() +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt new file mode 100644 index 000000000000..451f96dc10d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemMetadata.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +data class PlaybackItemMetadata( + val title: CharSequence, + val artist: CharSequence? = null, + val album: CharSequence? = null, + val genre: CharSequence? = null, + val year: Int? = null, + val description: CharSequence? = null, + val artworkData: ByteArray? = null, + val artworkUri: CharSequence? = null +) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt new file mode 100644 index 000000000000..735aa448f282 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackItemState.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.state + +import com.nextcloud.client.player.model.file.PlaybackFile +import java.io.Serializable + +data class PlaybackItemState( + val file: PlaybackFile, + val playerState: PlayerState, + val metadata: PlaybackItemMetadata?, + val videoSize: VideoSize?, + val currentTimeInMilliseconds: Long, + val maxTimeInMilliseconds: Long +) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt new file mode 100644 index 000000000000..6ec65e6e92cb --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlaybackState.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.state + +import com.nextcloud.client.player.model.file.PlaybackFile +import java.io.Serializable + +data class PlaybackState( + val currentFiles: List, + val currentItemState: PlaybackItemState?, + val repeatMode: RepeatMode, + val shuffle: Boolean +) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt b/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt new file mode 100644 index 000000000000..1caf340706f2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/PlayerState.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +enum class PlayerState : Serializable { + IDLE, + PLAYING, + PAUSED, + COMPLETED, + NONE +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt new file mode 100644 index 000000000000..91d0a30ee140 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/RepeatMode.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +enum class RepeatMode(val id: Int) : Serializable { + OFF(0), + SINGLE(1), + ALL(2) +} diff --git a/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt b/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt new file mode 100644 index 000000000000..9e94d2b4a922 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/model/state/VideoSize.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.model.state + +import java.io.Serializable + +data class VideoSize(val width: Int, val height: Int) : Serializable diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt new file mode 100644 index 000000000000..1527c09911f5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerActivity.kt @@ -0,0 +1,240 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Rect +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.util.Rational +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.core.view.ViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.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 +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +private const val PIP_ASPECT_RATIO_WIDTH = 16 +private const val PIP_ASPECT_RATIO_HEIGHT = 9 + +class PlayerActivity : + FileActivity(), + Injectable { + + companion object { + private const val PLAYBACK_FILE_TYPE: String = "PLAYBACK_FILE_TYPE" + + fun createIntent(context: Context, playbackFileType: PlaybackFileType): Intent = + Intent(context, PlayerActivity::class.java).apply { + putExtra(PLAYBACK_FILE_TYPE, playbackFileType) + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + } + } + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel by viewModels { viewModelFactory } + + private lateinit var playbackFileType: PlaybackFileType + + private lateinit var playerView: PlayerView + + private val pipAspectRatio = Rational(PIP_ASPECT_RATIO_WIDTH, PIP_ASPECT_RATIO_HEIGHT) + + private var onBackPressedCallback: OnBackPressedCallback? = null + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, windowInsets -> windowInsets } + + playbackFileType = intent.getPlaybackFileType() + createPlayerView() + + viewModel.eventFlow + .flowWithLifecycle(lifecycle) + .onEach { handleEvent(it) } + .launchIn(lifecycleScope) + + onBackPressedCallback = onBackPressedDispatcher.addCallback(this) { + val isVideoPlayback = playbackFileType == PlaybackFileType.VIDEO + + if (isPictureInPictureAllowed() && isVideoPlayback) { + switchToPictureInPictureMode() + } else { + file = file?.parentId?.let { storageManager.getFileById(it) } + finish() + } + } + + volumeControlStream = AudioManager.STREAM_MUSIC + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + playbackFileType = intent.getPlaybackFileType() + recreatePlayerView() + onBackPressedCallback?.isEnabled = canUsePictureInPictureMode() + } + + private fun createPlayerView() { + playerView = when (playbackFileType) { + PlaybackFileType.AUDIO -> AudioPlayerView(this) + PlaybackFileType.VIDEO -> VideoPlayerView(this) + } + val moreButton = playerView.findViewById(R.id.more) + moreButton.setOnClickListener { viewModel.onMoreButtonClick() } + setContentView(playerView) + } + + private fun recreatePlayerView() { + playerView.onStop() + createPlayerView() + playerView.onStart() + } + + private fun Intent.getPlaybackFileType(): PlaybackFileType = + getSerializableArgument(PLAYBACK_FILE_TYPE, PlaybackFileType::class.java) + ?: throw IllegalStateException("Playback file type was not defined") + + override fun onStart() { + super.onStart() + playerView.onStart() + } + + override fun onStop() { + super.onStop() + playerView.onStop() + } + + override fun onDestroy() { + super.onDestroy() + if (isFinishing && playbackFileType == PlaybackFileType.VIDEO) { + playbackModel.release() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + recreatePlayerView() + if (isInPictureInPictureMode) { + (playerView as? VideoPlayerView)?.hideControls() + } else { + (playerView as? VideoPlayerView)?.showControls() + } + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (canUsePictureInPictureMode()) { + switchToPictureInPictureMode() + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (!isInPictureInPictureMode && lifecycle.currentState == Lifecycle.State.CREATED) { + finish() // Finish the activity if the user closes the PIP window + } + } + + private fun canUsePictureInPictureMode(): Boolean = + playbackFileType == PlaybackFileType.VIDEO && isPictureInPictureAllowed() + + private fun switchToPictureInPictureMode() { + val params = createPictureInPictureParams() + enterPictureInPictureMode(params) + } + + private fun createPictureInPictureParams(): PictureInPictureParams = PictureInPictureParams.Builder().let { + it.setAspectRatio(pipAspectRatio) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + it.setAutoEnterEnabled(true) + } + 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 handleEvent(event: PlayerScreenEvent) { + when (event) { + is PlayerScreenEvent.ShowFileActions -> showFileActions(event.file, event.actionIds) + is PlayerScreenEvent.ShowFileDetails -> showFileDetails(event.file) + is PlayerScreenEvent.ShowFileExportStartedMessage -> showFileExportStartedMessage() + is PlayerScreenEvent.ShowShareFileDialog -> fileOperationsHelper.sendShareFile(event.file) + is PlayerScreenEvent.ShowRemoveFileDialog -> showRemoveFileDialog(event.file) + is PlayerScreenEvent.LaunchOpenFileIntent -> fileOperationsHelper.openFile(event.file) + is PlayerScreenEvent.LaunchStreamFileIntent -> fileOperationsHelper.streamMediaFile(event.file) + } + } + + private fun showFileActions(file: OCFile, actionIds: List) { + val actionsToHide = FileAction.entries.map(FileAction::id).filter { it !in actionIds } + FileActionsBottomSheet.newInstance(file, false, actionsToHide) + .setResultListener(supportFragmentManager, this) { viewModel.onFileActionChosen(file, it) } + .show(supportFragmentManager, "actions") + } + + private fun showFileDetails(file: OCFile) { + val intent = Intent(this, FileDisplayActivity::class.java).apply { + action = FileDisplayActivity.ACTION_DETAILS + putExtra(EXTRA_FILE, file) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + startActivity(intent) + finish() + } + + private fun showFileExportStartedMessage() { + val message = resources.getQuantityString(R.plurals.export_start, 1, 1) + DisplayUtils.showSnackMessage(playerView, message) + } + + private fun showRemoveFileDialog(file: OCFile) { + RemoveFilesDialogFragment.newInstance(file) + .show(supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt new file mode 100644 index 000000000000..d9912f12c756 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerLauncher.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.logger.Logger +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.media3.resumption.PlaybackResumptionConfigStore +import com.nextcloud.client.player.model.file.PlaybackFileType +import com.nextcloud.client.player.model.file.PlaybackFiles +import com.nextcloud.client.player.model.file.PlaybackFilesComparator +import com.nextcloud.client.player.model.file.PlaybackFilesRepository +import com.nextcloud.client.player.model.file.toPlaybackFile +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.SearchType +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.util.concurrent.CancellationException +import javax.inject.Inject + +class PlayerLauncher @Inject constructor( + private val playbackResumptionConfigStore: PlaybackResumptionConfigStore, + private val playbackFilesRepository: PlaybackFilesRepository, + private val playbackModel: PlaybackModel, + private val logger: Logger +) { + private var currentLaunchJob: Job? = null + + /** + * Starts playback and opens [PlayerActivity] on top of [activity]. + */ + fun launch(activity: AppCompatActivity, file: OCFile, searchType: SearchType?) { + run(activity) { + val fileType = prepareQueue(file, searchType) + playbackModel.play() + activity.startActivity(PlayerActivity.createIntent(activity, fileType)) + } + } + + /** + * Loads [file] and the media of its collection into the player without opening [PlayerActivity], so that a host + * screen can render the playback itself. + */ + fun prepare(owner: LifecycleOwner, file: OCFile, searchType: SearchType?, autoplay: Boolean = false) { + run(owner) { + prepareQueue(file, searchType) + if (autoplay) { + playbackModel.play() + } + } + } + + private fun run(owner: LifecycleOwner, block: suspend () -> Unit) { + currentLaunchJob?.cancel() + currentLaunchJob = owner.lifecycleScope.launch { + runCatching { block() }.onFailure { + if (it is CancellationException) throw it + logger.e(PlayerLauncher::class.java.simpleName, "Error launching player", it) + } + } + } + + private suspend fun prepareQueue(file: OCFile, searchType: SearchType?): PlaybackFileType { + val fileType = PlaybackFileType.ofMimeType(file.mimeType) + playbackResumptionConfigStore.saveConfig(file.localId.toString(), file.parentId, fileType, searchType) + + playbackModel.start() + playbackModel.setFiles(PlaybackFiles(listOf(file.toPlaybackFile()), PlaybackFilesComparator.NONE)) + playbackModel.setFilesFlow(playbackFilesRepository.observe(file.parentId, fileType, searchType)) + return fileType + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt new file mode 100644 index 000000000000..d70435d7d34d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerProgressIndicator.kt @@ -0,0 +1,87 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.AttrRes +import com.google.android.material.progressindicator.LinearProgressIndicator +import com.nextcloud.client.player.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.owncloud.android.datamodel.OCFile +import dagger.android.HasAndroidInjector +import javax.inject.Inject + +class PlayerProgressIndicator @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0 +) : LinearProgressIndicator(context, attrs, defStyleAttr), + PlaybackModel.Listener { + + @Inject + lateinit var playbackModel: PlaybackModel + + private var playbackFile: PlaybackFile? = null + + init { + indicatorTrackGapSize = 0 + trackStopIndicatorSize = 0 + if (!isInEditMode) { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!isInEditMode) { + renderCurrentState() + playbackModel.addListener(this) + } + } + + override fun onDetachedFromWindow() { + if (!isInEditMode) { + playbackModel.removeListener(this) + } + visibility = GONE + super.onDetachedFromWindow() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + val itemState = state.currentItemState + render(itemState) + } + + fun setFile(file: OCFile) { + playbackFile = file.toPlaybackFile() + renderCurrentState() + } + + private fun renderCurrentState() { + val itemState = playbackModel.state?.currentItemState + render(itemState) + } + + private fun render(itemState: PlaybackItemState?) { + if (itemState != null && + itemState.playerState != PlayerState.COMPLETED && + itemState.file.id == playbackFile?.id + ) { + max = itemState.maxTimeInMilliseconds.toInt() + progress = itemState.currentTimeInMilliseconds.toInt() + visibility = VISIBLE + } else { + visibility = GONE + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt new file mode 100644 index 000000000000..c5b0d23f066f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerScreenEvent.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +import com.owncloud.android.datamodel.OCFile + +sealed interface PlayerScreenEvent { + + data class ShowFileActions(val file: OCFile, val actionIds: List) : PlayerScreenEvent + + data class ShowFileDetails(val file: OCFile) : PlayerScreenEvent + + data object ShowFileExportStartedMessage : PlayerScreenEvent + + data class ShowShareFileDialog(val file: OCFile) : PlayerScreenEvent + + data class ShowRemoveFileDialog(val file: OCFile) : PlayerScreenEvent + + data class LaunchOpenFileIntent(val file: OCFile) : PlayerScreenEvent + + data class LaunchStreamFileIntent(val file: OCFile) : PlayerScreenEvent +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt new file mode 100644 index 000000000000..5c4fdf1f9ad3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt @@ -0,0 +1,154 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.CallSuper +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.model.error.SourceException +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.ui.control.PlayerControlView +import com.nextcloud.client.player.ui.pager.PlayerPager +import com.nextcloud.client.player.util.WindowWrapper +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.utils.DisplayUtils +import dagger.android.HasAndroidInjector +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +abstract class PlayerView @JvmOverloads constructor( + private val context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), + PlaybackModel.Listener { + + companion object { + private const val TAG = "PlayerView" + } + + @Inject + lateinit var playbackModel: PlaybackModel + + @Inject + lateinit var userAccountManager: UserAccountManager + + @get:LayoutRes + protected abstract val layoutRes: Int + + protected abstract val createFragment: (PlaybackFile) -> Fragment + + protected val activity: AppCompatActivity by lazy { context as AppCompatActivity } + protected val windowWrapper: WindowWrapper by lazy { WindowWrapper(activity.window) } + + protected val topBar: View by lazy { findViewById(R.id.topBar) } + protected val titleTextView: TextView by lazy { findViewById(R.id.title) } + protected val playerPager: PlayerPager by lazy { findViewById(R.id.playerPager) } + protected val playerControlView: PlayerControlView by lazy { findViewById(R.id.playerControlView) } + + init { + inflate(context, layoutRes, this) + if (!isInEditMode) { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + playerPager.initialize(activity.supportFragmentManager, createFragment) + playerPager.onItemSelected = { playbackModel.switchToFile(it) } + findViewById(R.id.back).setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() } + } + } + + @CallSuper + open fun onStart() { + val state = playbackModel.state + if (state == null) { + activity.finish() + return + } + + render(state) + playbackModel.addListener(this) + playerControlView.onStart() + } + + @CallSuper + open fun onStop() { + playbackModel.removeListener(this) + playerControlView.onStop() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + override fun onPlaybackError(error: Throwable) { + if (error is SourceException) { + downloadFile() + } else { + DisplayUtils.showSnackMessage(this, R.string.common_error_unknown) + } + } + + private fun downloadFile() { + val currentFile = playbackModel.state?.currentItemState?.file + val storageManager = FileDataStorageManager(userAccountManager.user, context.contentResolver) + val file = currentFile?.id?.toLong()?.let { storageManager.getFileByLocalId(it) } ?: return + + activity.lifecycleScope.launch(Dispatchers.IO) { + val operation = DownloadFileOperation(userAccountManager.user, file, context) + val client = OwnCloudClientManagerFactory.getDefaultSingleton() + .getClientFor(userAccountManager.currentOwnCloudAccount, context) + val result = operation.execute(client) + if (result.isSuccess) { + Log_OC.d(TAG, "file is successfully downloaded") + val helper = FileDownloadHelper() + helper.saveFile(file, operation, storageManager) + } else { + Log_OC.e(TAG, "cannot download file") + withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage(this@PlayerView, R.string.player_error_source_not_found) + } + } + } + } + + private fun render(state: PlaybackState) { + val currentFiles = state.currentFiles + if (state.currentFiles.isEmpty()) { + activity.finish() + return + } + + if (playerPager.getItems() != currentFiles) { + playerPager.setItems(currentFiles) + } + + if (state.currentItemState != null) { + val file = state.currentItemState.file + titleTextView.text = file.getNameWithoutExtension() + playerPager.setCurrentItem(file) + } else { + titleTextView.text = "" + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt new file mode 100644 index 000000000000..df14c565c931 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/PlayerViewModel.kt @@ -0,0 +1,105 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui + +import androidx.core.text.isDigitsOnly +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.download.FileDownloadHelper +import com.nextcloud.client.logger.Logger +import com.nextcloud.client.player.media3.PlaybackModel +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +class PlayerViewModel @Inject constructor( + private val playbackModel: PlaybackModel, + private val storageManager: FileDataStorageManager, + private val userAccountManager: UserAccountManager, + private val backgroundJobManager: BackgroundJobManager, + private val logger: Logger +) : ViewModel() { + + private val eventChannel = Channel(Channel.BUFFERED) + val eventFlow: Flow = eventChannel.receiveAsFlow() + + fun onMoreButtonClick() { + viewModelScope.launch { + val file = getCurrentOCFile() ?: return@launch + val actionIds = listOf( + R.id.action_see_details, + R.id.action_download_file, + R.id.action_export_file, + R.id.action_send_share_file, + R.id.action_remove_file, + R.id.action_open_file_with, + R.id.action_stream_media + ) + eventChannel.trySend(PlayerScreenEvent.ShowFileActions(file, actionIds)) + } + } + + fun onFileActionChosen(file: OCFile, actionId: Int) { + when (actionId) { + R.id.action_see_details -> eventChannel.trySend(PlayerScreenEvent.ShowFileDetails(file)) + R.id.action_download_file -> startFileDownloading(file) + R.id.action_export_file -> startFileExport(file) + R.id.action_send_share_file -> eventChannel.trySend(PlayerScreenEvent.ShowShareFileDialog(file)) + R.id.action_remove_file -> eventChannel.trySend(PlayerScreenEvent.ShowRemoveFileDialog(file)) + R.id.action_open_file_with -> onOpenFileWithClick(file) + R.id.action_stream_media -> onStreamFileClick(file) + } + } + + private suspend fun getCurrentOCFile(): OCFile? { + val currentFileId = playbackModel.state?.currentItemState?.file?.id + return currentFileId + ?.takeIf { it.isDigitsOnly() } + ?.let { getOCFile(it.toLong()) } + } + + private suspend fun getOCFile(localId: Long): OCFile? = withContext(Dispatchers.IO) { + runCatching { + storageManager.getFileByLocalId(localId) + }.getOrElse { + if (it is CancellationException) throw it + logger.e(PlayerViewModel::class.java.simpleName, "Failed to get file by localId: $localId", it) + null + } + } + + private fun startFileDownloading(file: OCFile) { + val user = userAccountManager.user + FileDownloadHelper.instance().downloadFileIfNotStartedBefore(user, file) + } + + private fun startFileExport(file: OCFile) { + backgroundJobManager.startImmediateFilesExportJob(listOf(file)) + eventChannel.trySend(PlayerScreenEvent.ShowFileExportStartedMessage) + } + + private fun onOpenFileWithClick(file: OCFile) { + playbackModel.pause() + eventChannel.trySend(PlayerScreenEvent.LaunchOpenFileIntent(file)) + } + + private fun onStreamFileClick(file: OCFile) { + playbackModel.pause() + eventChannel.trySend(PlayerScreenEvent.LaunchStreamFileIntent(file)) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt new file mode 100644 index 000000000000..790d4d5bf9a9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -0,0 +1,124 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.audio + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackItemMetadata +import com.nextcloud.client.player.model.state.PlaybackState +import com.owncloud.android.R +import com.owncloud.android.databinding.PlayerAudioFileFragmentBinding +import com.owncloud.android.utils.DisplayUtils +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import javax.inject.Inject + +open class AudioFileFragment : + Fragment(), + PlaybackModel.Listener { + + companion object { + private const val ARGUMENT_FILE = "ARGUMENT_FILE" + + fun createInstance(file: PlaybackFile) = AudioFileFragment().apply { + arguments = bundleOf(ARGUMENT_FILE to file) + } + } + + @Inject + lateinit var playbackModel: PlaybackModel + + @Inject + lateinit var thumbnailLoader: ThumbnailLoader + + private lateinit var binding: PlayerAudioFileFragmentBinding + private lateinit var loadFileThumbnailJob: Job + private var isFileThumbnailLoaded = false + private var metadata: PlaybackItemMetadata? = null + private val file by lazy { arguments?.getSerializable(ARGUMENT_FILE) as PlaybackFile } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AndroidSupportInjection.inject(this) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = PlayerAudioFileFragmentBinding.inflate(inflater, container, false) + binding.title.isSelected = true + binding.title.text = file.getNameWithoutExtension() + binding.fileDetails.text = file.getDetailsText() + loadFileThumbnailJob = loadFileThumbnail() + return binding.getRoot() + } + + override fun onStart() { + super.onStart() + playbackModel.state?.let(::onPlaybackUpdate) + playbackModel.addListener(this) + } + + override fun onStop() { + playbackModel.removeListener(this) + super.onStop() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + state.currentItemState?.let { + if (it.file.id == file.id && it.metadata != null && it.metadata != metadata) { + onMetadataUpdate(it.metadata) + } + } + } + + private fun onMetadataUpdate(metadata: PlaybackItemMetadata) { + this.metadata = metadata + if (!isFileThumbnailLoaded && (metadata.artworkData != null || metadata.artworkUri != null)) { + loadFileThumbnailJob.takeIf { it.isActive }?.cancel() + loadMetadataArtwork(metadata) + } + binding.title.text = if (metadata.artist.isNullOrEmpty()) { + metadata.title + } else { + "${metadata.artist} • ${metadata.title}" + } + } + + private fun loadFileThumbnail(): Job = viewLifecycleOwner.lifecycleScope.launch { + val thumbnailSize = resources.getDimension(R.dimen.player_album_cover_size).toInt() + val thumbnail = thumbnailLoader.await(requireContext(), file, thumbnailSize, thumbnailSize) + if (thumbnail != null) { + binding.albumCover.setImageBitmap(thumbnail) + isFileThumbnailLoaded = true + } + } + + private fun loadMetadataArtwork(metadata: PlaybackItemMetadata) { + val source = metadata.artworkData ?: metadata.artworkUri ?: return + thumbnailLoader.load(binding.albumCover, source, file.id) + } + + private fun PlaybackFile.getDetailsText(): String { + val size = if (contentLength > 0) DisplayUtils.bytesToHumanReadable(contentLength) else "" + val date = if (lastModified > 0) getLastModifiedText(lastModified) else "" + return if (size.isNotEmpty() && date.isNotEmpty()) "$size, $date" else size + date + } + + private fun getLastModifiedText(lastModified: Long): String { + val relativeTimestamp = DisplayUtils.getRelativeTimestamp(context, lastModified) + return getString(R.string.player_last_modified, relativeTimestamp) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt new file mode 100644 index 000000000000..2681412967a7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioPlayerView.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.audio + +import android.content.Context +import android.view.WindowInsets +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type +import androidx.fragment.app.Fragment +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.ui.PlayerView +import com.owncloud.android.R + +class AudioPlayerView(context: Context) : PlayerView(context) { + + override val layoutRes get() = R.layout.player_audio_view + + override val createFragment: (PlaybackFile) -> Fragment get() = { AudioFileFragment.createInstance(it) } + + override fun onStart() { + super.onStart() + windowWrapper.showSystemBars() + } + + override fun onApplyWindowInsets(windowInsets: WindowInsets): WindowInsets? { + val windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets) + val insets = windowInsetsCompat.getInsets(Type.systemBars() or Type.displayCutout()) + + topBar.setPadding(insets.left, insets.top, insets.right, 0) + playerPager.setPadding(insets.left, 0, insets.right, 0) + playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom) + + windowWrapper.setupStatusBar(R.color.player_background_color, false) + windowWrapper.setupNavigationBar(R.color.player_background_color, true) + + return WindowInsetsCompat.CONSUMED.toWindowInsets() + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt new file mode 100644 index 000000000000..39957620b263 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/MultipleClickListener.kt @@ -0,0 +1,41 @@ +/* + * 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 new file mode 100644 index 000000000000..86baf378b418 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/control/PlayerControlView.kt @@ -0,0 +1,245 @@ +/* + * 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.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.model.state.PlaybackItemState +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.PlayerState +import com.nextcloud.client.player.model.state.RepeatMode +import com.owncloud.android.R +import com.owncloud.android.databinding.PlayerControlViewBinding +import dagger.android.HasAndroidInjector +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +private const val INDETERMINATE_TIME = "--:--" +private const val TAG_CLICK_COMMAND_PLAY = "TAG_CLICK_COMMAND_PLAY" +private const val TAG_CLICK_COMMAND_PAUSE = "TAG_CLICK_COMMAND_PAUSE" +private const val TAG_CLICK_COMMAND_REPEAT = "TAG_CLICK_COMMAND_REPEAT" +private const val TAG_CLICK_COMMAND_DO_NOT_REPEAT = "TAG_CLICK_COMMAND_DO_NOT_REPEAT" +private const val TAG_CLICK_COMMAND_SHUFFLE = "TAG_CLICK_COMMAND_SHUFFLE" +private const val TAG_CLICK_COMMAND_DO_NOT_SHUFFLE = "TAG_CLICK_COMMAND_DO_NOT_SHUFFLE" +private const val TAG_CLICK_COMMAND_UNKNOWN = "TAG_CLICK_COMMAND_UNKNOWN" + +private const val PROGRESS_CHANGE_DEBOUNCE_MS = 200L +private const val DEFAULT_MIN_PROGRESS = 0 +private const val DEFAULT_MAX_PROGRESS = 100 +private const val MILLISECONDS_IN_SECOND = 1000 +private const val MILLISECONDS_IN_HOUR = 3_600_000 +private const val SECONDS_IN_MINUTE = 60 +private const val MINUTES_IN_HOUR = 60 + +class PlayerControlView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), + PlaybackModel.Listener { + + @Inject + lateinit var playbackModel: PlaybackModel + + private val seekBarProgressChangeFlow = MutableSharedFlow(extraBufferCapacity = 1) + private var viewScope: CoroutineScope? = null + + val binding = PlayerControlViewBinding.inflate(LayoutInflater.from(context), this, true) + + init { + if (!isInEditMode) { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + setDefaultTags() + setListeners() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!isInEditMode) { + viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + collectSeekBarChanges() + } + } + + override fun onDetachedFromWindow() { + if (!isInEditMode) { + viewScope?.cancel() + viewScope = null + } + super.onDetachedFromWindow() + } + + fun onStart() { + playbackModel.state?.let(::render) + playbackModel.addListener(this) + } + + fun onStop() { + playbackModel.removeListener(this) + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + private fun setDefaultTags() { + binding.ivPlayPause.tag = TAG_CLICK_COMMAND_UNKNOWN + binding.ivRandom.tag = TAG_CLICK_COMMAND_UNKNOWN + binding.ivRepeat.tag = TAG_CLICK_COMMAND_UNKNOWN + } + + private fun setListeners() { + binding.ivPlayPause.setOnClickListener { + when (binding.ivPlayPause.tag) { + TAG_CLICK_COMMAND_PLAY -> playbackModel.play() + TAG_CLICK_COMMAND_PAUSE -> playbackModel.pause() + } + } + + binding.ivRepeat.setOnClickListener { + when (binding.ivRepeat.tag) { + TAG_CLICK_COMMAND_REPEAT -> playbackModel.setRepeatMode(RepeatMode.SINGLE) + TAG_CLICK_COMMAND_DO_NOT_REPEAT -> playbackModel.setRepeatMode(RepeatMode.ALL) + } + } + + binding.ivRandom.setOnClickListener { + playbackModel.setShuffle(binding.ivRandom.tag == TAG_CLICK_COMMAND_SHUFFLE) + } + + binding.ivNext.setOnClickListener { playbackModel.playNext() } + + binding.ivPrevious.setOnClickListener( + MultipleClickListener( + onSingleClick = { + playbackModel.state?.currentItemState?.let { state -> + if (state.playerState == PlayerState.PAUSED || state.playerState == PlayerState.PLAYING) { + playbackModel.seekToPosition(0L) + } else { + playbackModel.playPrevious() + } + } + }, + onDoubleClick = { playbackModel.playPrevious() } + ) + ) + + binding.progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + seekBarProgressChangeFlow.tryEmit(progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit + }) + } + + @OptIn(FlowPreview::class) + private fun collectSeekBarChanges() { + val viewScope = viewScope ?: return + val lifecycleOwner = (context as? LifecycleOwner) ?: return + seekBarProgressChangeFlow + .debounce(PROGRESS_CHANGE_DEBOUNCE_MS) + .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .onEach { playbackModel.seekToPosition(it.toLong()) } + .launchIn(viewScope) + } + + private fun render(playbackState: PlaybackState) { + renderRepeatButton(playbackState.repeatMode == RepeatMode.SINGLE) + renderShuffleButton(playbackState.shuffle) + renderPlayPauseButton(playbackState.currentItemState?.playerState == PlayerState.PLAYING) + renderNextPreviousButtons(playbackState) + renderProgressBar(playbackState.currentItemState) + } + + private fun renderRepeatButton(repeatSingle: Boolean) { + binding.ivRepeat.iconTint = ContextCompat.getColorStateList( + binding.root.context, + if (repeatSingle) { + R.color.player_accent_color + } else { + R.color.player_default_icon_color + } + ) + binding.ivRepeat.tag = if (repeatSingle) TAG_CLICK_COMMAND_DO_NOT_REPEAT else TAG_CLICK_COMMAND_REPEAT + } + + private fun renderShuffleButton(shuffle: Boolean) { + binding.ivRandom.iconTint = ContextCompat.getColorStateList( + binding.root.context, + if (shuffle) { + R.color.player_accent_color + } else { + R.color.player_default_icon_color + } + ) + binding.ivRandom.tag = if (shuffle) TAG_CLICK_COMMAND_DO_NOT_SHUFFLE else TAG_CLICK_COMMAND_SHUFFLE + } + + private fun renderPlayPauseButton(isPlaying: Boolean) { + binding.ivPlayPause.icon = AppCompatResources.getDrawable( + binding.root.context, + if (isPlaying) { + R.drawable.player_ic_pause + } else { + R.drawable.player_ic_play + } + ) + binding.ivPlayPause.tag = if (isPlaying) TAG_CLICK_COMMAND_PAUSE else TAG_CLICK_COMMAND_PLAY + } + + private fun renderNextPreviousButtons(playbackState: PlaybackState) { + binding.ivNext.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.size > 1) + binding.ivPrevious.setEnabled(playbackState.currentItemState != null && playbackState.currentFiles.isNotEmpty()) + } + + private fun renderProgressBar(playbackItemState: PlaybackItemState?) { + val enabled = playbackItemState != null && playbackItemState.maxTimeInMilliseconds > DEFAULT_MIN_PROGRESS + val max = if (enabled) playbackItemState.maxTimeInMilliseconds.toInt() else DEFAULT_MAX_PROGRESS + val progress = if (enabled) playbackItemState.currentTimeInMilliseconds.toInt() else DEFAULT_MIN_PROGRESS + binding.progressBar.isEnabled = enabled + binding.progressBar.max = max + binding.progressBar.progress = progress + binding.tvElapsed.text = if (enabled) formatTime(progress, max) else INDETERMINATE_TIME + binding.tvTotalTime.text = if (enabled) formatTime(max, max) else INDETERMINATE_TIME + } + + private fun formatTime(current: Int, max: Int): String { + val seconds = current / MILLISECONDS_IN_SECOND + val minutes = seconds / SECONDS_IN_MINUTE + val hours = minutes / MINUTES_IN_HOUR + return if (max >= MILLISECONDS_IN_HOUR) { + "%02d:%02d:%02d".format(hours, minutes % MINUTES_IN_HOUR, seconds % SECONDS_IN_MINUTE) + } else { + "%02d:%02d".format(minutes, seconds % SECONDS_IN_MINUTE) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt new file mode 100644 index 000000000000..811bb23c6147 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPager.kt @@ -0,0 +1,127 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.pager + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.viewpager.widget.ViewPager +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.util.rotate +import com.owncloud.android.R + +private const val NO_SHIFT = -1 + +class PlayerPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + LinearLayout(context, attrs) { + + private val viewPager: ViewPager + private lateinit var 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 + + override fun onPageSelected(position: Int) { + if (position == 0) { + viewPager.setCurrentItem(adapter.count - 2, false) + return + } + if (position >= adapter.count - 1) { + viewPager.setCurrentItem(1, false) + return + } + onItemSelected?.invoke(adapter.getEntityForPosition(position)) + } + + override fun onPageScrollStateChanged(state: Int) = Unit + } + + init { + inflate(context, R.layout.player_pager, this) + viewPager = findViewById(R.id.viewPager) + } + + fun initialize(fragmentManager: FragmentManager, createFragment: (PlaybackFile) -> Fragment) { + adapter = PlayerPagerAdapter(fragmentManager, createFragment) + viewPager.adapter = adapter + } + + override fun onSaveInstanceState(): Parcelable { + val state = PlayerPagerState(super.onSaveInstanceState()) + state.shiftedPosition = shift + return state + } + + override fun onRestoreInstanceState(state: Parcelable?) { + val restoredState = state as PlayerPagerState + super.onRestoreInstanceState(restoredState.superState) + restoredShift = restoredState.shiftedPosition + } + + fun getItems(): List = adapter.getEntities() + + fun setItems(items: List) { + adapter.setEntities(if (restoredShift != NO_SHIFT) shiftRestoredPosition(items) else items) + notifyDataSetChangedWithoutCallingListener() + } + + fun setCurrentItem(item: PlaybackFile) { + val position = adapter.getEntityIndex(item) + if (position != -1 && viewPager.currentItem != position) { + viewPager.removeOnPageChangeListener(onPageChangeListener) + viewPager.setCurrentItem(position, true) + viewPager.addOnPageChangeListener(onPageChangeListener) + } + } + + private fun notifyDataSetChangedWithoutCallingListener() { + viewPager.removeOnPageChangeListener(onPageChangeListener) + adapter.notifyDataSetChanged() + viewPager.addOnPageChangeListener(onPageChangeListener) + } + + private fun shiftRestoredPosition(items: List): List { + shift = restoredShift + restoredShift = NO_SHIFT + return items.rotate(shift) + } + + class PlayerPagerState : BaseSavedState { + var shiftedPosition: Int = 0 + + constructor(superState: Parcelable?) : super(superState) + + constructor(parcel: Parcel) : super(parcel) { + shiftedPosition = parcel.readInt() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(shiftedPosition) + } + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + + override fun createFromParcel(parcel: Parcel): PlayerPagerState = PlayerPagerState(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt new file mode 100644 index 000000000000..2e016d7deb99 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/pager/PlayerPagerAdapter.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.pager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import com.nextcloud.client.player.model.file.PlaybackFile + +/** + * Pads the item list with a copy of the last item at the front and a copy of the first item at the end, so that + * [PlayerPager] can wrap around and give the impression of an endless pager. + */ +@Suppress("DEPRECATION") +class PlayerPagerAdapter(fragmentManager: FragmentManager, private val createFragment: (PlaybackFile) -> Fragment) : + FragmentStatePagerAdapter(fragmentManager) { + + private var paddedEntities = mutableListOf() + + fun getEntities(): List = if (isPadded()) removeStubs(paddedEntities) else paddedEntities + + fun setEntities(entities: List) { + paddedEntities = if (entities.size > 1) addStubs(entities) else entities.toMutableList() + notifyDataSetChanged() + } + + fun getEntityIndex(entity: PlaybackFile): Int = if (isPadded()) { + val index = removeStubs(paddedEntities).indexOf(entity) + if (index != -1) index + 1 else index + } else { + paddedEntities.indexOf(entity) + } + + fun getEntityForPosition(position: Int): PlaybackFile = paddedEntities[position] + + override fun getItem(position: Int): Fragment = createFragment(paddedEntities[position]) + + override fun getCount(): Int = paddedEntities.size + + override fun getItemPosition(item: Any): Int = POSITION_NONE + + private fun isPadded(): Boolean = paddedEntities.size > 1 + + private fun addStubs(sources: List): MutableList { + val result = sources.toMutableList() + result.add(0, result[result.size - 1]) + result.add(result[1]) + return result + } + + private fun removeStubs(sources: List): MutableList { + val result = sources.toMutableList() + result.removeAt(0) + result.removeAt(result.size - 1) + return result + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt new file mode 100644 index 000000000000..0cf4919e9e7f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoFileFragment.kt @@ -0,0 +1,113 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.video + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.VideoSize +import com.nextcloud.client.player.util.applyVideoSize +import com.nextcloud.utils.extensions.getSerializableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.PlayerVideoFileFragmentBinding +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.launch +import javax.inject.Inject + +class VideoFileFragment : + Fragment(), + PlaybackModel.Listener { + + companion object { + private const val ARGUMENT_FILE = "ARGUMENT_FILE" + + fun createInstance(file: PlaybackFile) = VideoFileFragment().apply { + arguments = bundleOf(ARGUMENT_FILE to file) + } + } + + @Inject + lateinit var playerModel: PlaybackModel + + @Inject + lateinit var thumbnailLoader: ThumbnailLoader + + private lateinit var file: PlaybackFile + + private lateinit var binding: PlayerVideoFileFragmentBinding + + private var previousVideoSize: VideoSize? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AndroidSupportInjection.inject(this) + val playbackFile = arguments.getSerializableArgument(ARGUMENT_FILE, PlaybackFile::class.java) + this.file = playbackFile ?: throw IllegalArgumentException("bundle is not containing playback file") + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = PlayerVideoFileFragmentBinding.inflate(inflater, container, false) + loadFileThumbnail() + return binding.root + } + + override fun onStart() { + super.onStart() + render(playerModel.state) + playerModel.addListener(this) + } + + override fun onStop() { + playerModel.removeListener(this) + super.onStop() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + private fun loadFileThumbnail() { + viewLifecycleOwner.lifecycleScope.launch { + val context = context ?: return@launch + val thumbnailSize = context.resources.getDimension(R.dimen.player_album_cover_size) + val thumbnail = thumbnailLoader.await(context, file, thumbnailSize.toInt(), thumbnailSize.toInt()) + thumbnail?.let(binding.thumbnail::setImageBitmap) + } + } + + private fun render(state: PlaybackState?) { + val currentItemState = state?.currentItemState + if (currentItemState?.file == file) { + showVideo(currentItemState.videoSize) + } else { + binding.surfaceView.visibility = View.GONE + if (currentItemState == null) { + playerModel.setVideoSurfaceView(null) + } + } + } + + private fun showVideo(videoSize: VideoSize?) { + playerModel.setVideoSurfaceView(binding.surfaceView) + binding.surfaceView.visibility = View.VISIBLE + binding.surfaceView.alpha = if (videoSize != null) 1f else 0f + + if (videoSize != null && previousVideoSize != videoSize) { + previousVideoSize = videoSize + binding.surfaceView.applyVideoSize(videoSize) + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt new file mode 100644 index 000000000000..9bb0c548fdcd --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/ui/video/VideoPlayerView.kt @@ -0,0 +1,99 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.ui.video + +import android.content.Context +import android.view.MotionEvent +import android.view.WindowInsets +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.ui.PlayerView +import com.owncloud.android.R +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class VideoPlayerView(context: Context) : PlayerView(context) { + + companion object { + private const val HIDE_CONTROLS_DELAY = 5000L + } + + override val layoutRes get() = R.layout.player_video_view + + override val createFragment: (PlaybackFile) -> Fragment get() = { VideoFileFragment.createInstance(it) } + + private var hideControlsTimerJob: Job? = null + + override fun onStart() { + super.onStart() + showControls() + } + + override fun onStop() { + super.onStop() + cancelHideControlsTimer() + playbackModel.setVideoSurfaceView(null) + } + + override fun onApplyWindowInsets(windowInsets: WindowInsets): WindowInsets? { + val windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets) + val insets = windowInsetsCompat.getInsets(Type.systemBars() or Type.displayCutout()) + + topBar.setPadding(insets.left, insets.top, insets.right, 0) + playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom) + + windowWrapper.setupStatusBar(R.color.player_video_toolbar_background_color, false) + windowWrapper.setupNavigationBar(R.color.player_video_control_view_background_color, false) + + return WindowInsetsCompat.CONSUMED.toWindowInsets() + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { + val isTouchOutsideControls = event.y < playerControlView.y && event.y > topBar.height + when { + !playerControlView.isVisible -> showControls() + isTouchOutsideControls -> hideControls() + else -> restartHideControlsTimer() + } + } + return super.dispatchTouchEvent(event) + } + + fun showControls() { + windowWrapper.showSystemBars() + topBar.visibility = VISIBLE + playerControlView.visibility = VISIBLE + restartHideControlsTimer() + } + + fun hideControls() { + windowWrapper.hideSystemBars() + topBar.visibility = GONE + playerControlView.visibility = GONE + cancelHideControlsTimer() + } + + private fun restartHideControlsTimer() { + hideControlsTimerJob?.cancel() + hideControlsTimerJob = activity.lifecycleScope.launch { + delay(HIDE_CONTROLS_DELAY) + hideControls() + } + } + + private fun cancelHideControlsTimer() { + hideControlsTimerJob?.cancel() + hideControlsTimerJob = null + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt new file mode 100644 index 000000000000..b61117bf5987 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/ContentResolver.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: 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 new file mode 100644 index 000000000000..7a7080565b95 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/Context.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: 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 new file mode 100644 index 000000000000..05c238c78a1d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/List.kt @@ -0,0 +1,16 @@ +/* + * 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/PeriodicAction.kt b/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt new file mode 100644 index 000000000000..b815070c9cb4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/PeriodicAction.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.util + +import android.os.Handler +import android.os.Looper + +class PeriodicAction(private val periodicIntervalInMilliseconds: Long, private val action: () -> Unit) { + private val handler = Handler(Looper.getMainLooper()) + + private val runnable = Runnable { + action.invoke() + start() + } + + fun start() { + stop() + handler.postDelayed(runnable, periodicIntervalInMilliseconds) + } + + fun stop() { + handler.removeCallbacks(runnable) + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt new file mode 100644 index 000000000000..9681bb72d0b4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/ScreenUtils.kt @@ -0,0 +1,35 @@ +/* + * 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 new file mode 100644 index 000000000000..dcbfd3555790 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/SurfaceView.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.util + +import android.view.SurfaceView +import android.view.ViewGroup +import com.nextcloud.client.player.model.state.VideoSize + +/** + * Letterboxes the surface inside its container so the video keeps its aspect ratio. + */ +fun SurfaceView.applyVideoSize(videoSize: VideoSize) { + val screenWidth = context.getDisplayWidth() + val screenHeight = context.getDisplayHeight() + val screenProportion = screenWidth.toFloat() / screenHeight.toFloat() + val videoProportion = videoSize.width.toFloat() / videoSize.height.toFloat() + + layoutParams = layoutParams.apply { + if (screenProportion < videoProportion) { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = (screenWidth.toFloat() / videoProportion).toInt() + } else { + width = (videoProportion * screenHeight.toFloat()).toInt() + height = ViewGroup.LayoutParams.MATCH_PARENT + } + } +} diff --git a/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt new file mode 100644 index 000000000000..5a8fa141836d --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/player/util/WindowWrapper.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 STRATO GmbH. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.player.util + +import android.os.Build +import android.view.Window +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +private const val LUMINANCE_THRESHOLD = 0.5 + +class WindowWrapper(private val window: Window) { + private val context = window.context + private val insetsController = WindowCompat.getInsetsController(window, window.decorView) + + fun showSystemBars() { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + + fun hideSystemBars() { + insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + } + + fun setupStatusBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) { + val backgroundColor = ContextCompat.getColor(context, backgroundColorRes) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.setStatusBarContrastEnforced(contrastEnforced) + } + insetsController.isAppearanceLightStatusBars = isLightColor(backgroundColor) + } + + fun setupNavigationBar(@ColorRes backgroundColorRes: Int, contrastEnforced: Boolean) { + val backgroundColor = ContextCompat.getColor(context, backgroundColorRes) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.setNavigationBarContrastEnforced(contrastEnforced) + } + window.navigationBarColor = backgroundColor + insetsController.isAppearanceLightNavigationBars = isLightColor(backgroundColor) + } + + private fun isLightColor(@ColorInt color: Int): Boolean = ColorUtils.calculateLuminance(color) > LUMINANCE_THRESHOLD +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 1db87a82ea63..5dc81f1da345 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2000,6 +2000,35 @@ private ArrayList prepareRemoveSharesInFile( } + public List getShares() { + String selection = ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + " = ?"; + String[] selectionArgs = new String[]{user.getAccountName()}; + + Cursor cursor = null; + Uri uri = ProviderTableMeta.CONTENT_URI_SHARE; + if (getContentResolver() != null) { + cursor = getContentResolver().query(uri, null, selection, selectionArgs, null); + } else { + try { + cursor = getContentProviderClient().query(uri, null, selection, selectionArgs, null); + } catch (RemoteException e) { + Log_OC.e(TAG, "Could not get list of shares: " + e.getMessage(), e); + } + } + + ArrayList shares = new ArrayList<>(); + if (cursor != null) { + if (cursor.moveToFirst()) { + do { + shares.add(createShareInstance(cursor)); + } while (cursor.moveToNext()); + } + cursor.close(); + } + + return shares; + } + public List getSharesWithForAFile(String filePath, String accountName) { String selection = ProviderTableMeta.OCSHARES_PATH + AND + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + AND @@ -2844,6 +2873,17 @@ public List getAllFiles() { return folderContent; } + public List getFavoriteFiles() { + List fileEntities = fileDao.getFavoriteFilesNonBlocking(user.getAccountName()); + List favoriteFiles = new ArrayList<>(fileEntities.size()); + + for (FileEntity fileEntity : fileEntities) { + favoriteFiles.add(createFileInstance(fileEntity)); + } + + return favoriteFiles; + } + private String getString(Cursor cursor, String columnName) { return cursor.getString(cursor.getColumnIndexOrThrow(columnName)); } diff --git a/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java deleted file mode 100644 index 6d12aed0aaa1..000000000000 --- a/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2018 Tobias Kaminsky - * SPDX-FileCopyrightText: 2018 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.files; - -import com.owncloud.android.lib.common.OwnCloudClient; -import com.owncloud.android.lib.common.operations.RemoteOperation; -import com.owncloud.android.lib.common.operations.RemoteOperationResult; -import com.owncloud.android.lib.common.utils.Log_OC; - -import org.apache.commons.httpclient.HttpStatus; -import org.apache.commons.httpclient.methods.Utf8PostMethod; -import org.json.JSONObject; - -import java.util.ArrayList; - -public class StreamMediaFileOperation extends RemoteOperation { - private static final String TAG = StreamMediaFileOperation.class.getSimpleName(); - private static final int SYNC_READ_TIMEOUT = 40000; - private static final int SYNC_CONNECTION_TIMEOUT = 5000; - private static final String STREAM_MEDIA_URL = "/ocs/v2.php/apps/dav/api/v1/direct"; - - private final long fileID; - - // JSON node names - private static final String NODE_OCS = "ocs"; - private static final String NODE_DATA = "data"; - private static final String NODE_URL = "url"; - private static final String JSON_FORMAT = "?format=json"; - - public StreamMediaFileOperation(long fileID) { - this.fileID = fileID; - } - - protected RemoteOperationResult run(OwnCloudClient client) { - RemoteOperationResult result; - Utf8PostMethod postMethod = null; - - try { - postMethod = new Utf8PostMethod(client.getBaseUri() + STREAM_MEDIA_URL + JSON_FORMAT); - postMethod.setParameter("fileId", String.valueOf(fileID)); - - // remote request - postMethod.addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE); - - int status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT); - - if (status == HttpStatus.SC_OK) { - String response = postMethod.getResponseBodyAsString(); - - // Parse the response - JSONObject respJSON = new JSONObject(response); - String url = respJSON.getJSONObject(NODE_OCS).getJSONObject(NODE_DATA).getString(NODE_URL); - - result = new RemoteOperationResult(true, postMethod); - ArrayList urlArray = new ArrayList<>(); - urlArray.add(url); - result.setData(urlArray); - } else { - result = new RemoteOperationResult(false, postMethod); - client.exhaustResponse(postMethod.getResponseBodyAsStream()); - } - } catch (Exception e) { - result = new RemoteOperationResult(e); - Log_OC.e(TAG, "Get stream url for file with id " + fileID + " failed: " + result.getLogMessage(), - result.getException()); - } finally { - if (postMethod != null) { - postMethod.releaseConnection(); - } - } - return result; - } -} diff --git a/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt new file mode 100644 index 000000000000..0ebc51c9c4df --- /dev/null +++ b/app/src/main/java/com/owncloud/android/files/StreamMediaFileOperation.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.files + +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import org.apache.commons.httpclient.HttpStatus +import org.apache.commons.httpclient.methods.Utf8PostMethod +import org.json.JSONObject + +@Suppress("TooGenericExceptionCaught") +class StreamMediaFileOperation(private val fileID: Long) : RemoteOperation>() { + + @Deprecated("Deprecated in Java") + override fun run(client: OwnCloudClient): RemoteOperationResult> { + val postMethod = Utf8PostMethod(client.baseUri.toString() + STREAM_MEDIA_URL + JSON_FORMAT) + + return try { + postMethod.apply { + setParameter("fileId", fileID.toString()) + addRequestHeader(OCS_API_HEADER, OCS_API_HEADER_VALUE) + params.soTimeout = SYNC_READ_TIMEOUT + } + + val status = client.executeMethod(postMethod, SYNC_READ_TIMEOUT, SYNC_CONNECTION_TIMEOUT) + + if (status == HttpStatus.SC_OK) { + val response = postMethod.getResponseBodyAsString() + val url = JSONObject(response) + .getJSONObject(NODE_OCS) + .getJSONObject(NODE_DATA) + .getString(NODE_URL) + + RemoteOperationResult>(true, postMethod).also { + it.data = arrayListOf(url) + } + } else { + client.exhaustResponse(postMethod.getResponseBodyAsStream()) + RemoteOperationResult(false, postMethod) + } + } catch (e: Exception) { + Log_OC.e(TAG, "Get stream url for file with id $fileID failed: ${e.message}", e) + RemoteOperationResult(e) + } finally { + postMethod.releaseConnection() + } + } + + companion object { + private val TAG = StreamMediaFileOperation::class.java.simpleName + private const val SYNC_READ_TIMEOUT = 120_000 + private const val SYNC_CONNECTION_TIMEOUT = 15_000 + private const val STREAM_MEDIA_URL = "/ocs/v2.php/apps/dav/api/v1/direct" + private const val NODE_OCS = "ocs" + private const val NODE_DATA = "data" + private const val NODE_URL = "url" + private const val JSON_FORMAT = "?format=json" + } +} diff --git a/app/src/main/java/com/owncloud/android/media/MediaControlView.kt b/app/src/main/java/com/owncloud/android/media/MediaControlView.kt deleted file mode 100644 index 217c1c153501..000000000000 --- a/app/src/main/java/com/owncloud/android/media/MediaControlView.kt +++ /dev/null @@ -1,355 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Alper Ozturk - * SPDX-FileCopyrightText: 2022 Álvaro Brey Vilas - * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2018 Andy Scherzinger - * SPDX-FileCopyrightText: 2015 ownCloud Inc. - * SPDX-FileCopyrightText: 2013 David A. Velasco - * SPDX-License-Identifier: GPL-2.0-only AND AGPL-3.0-or-later - */ -package com.owncloud.android.media - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.util.AttributeSet -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.View -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo -import android.widget.LinearLayout -import android.widget.SeekBar -import android.widget.SeekBar.OnSeekBarChangeListener -import androidx.core.content.ContextCompat -import androidx.media3.common.Player -import com.owncloud.android.MainApp -import com.owncloud.android.R -import com.owncloud.android.databinding.MediaControlBinding -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.utils.theme.ViewThemeUtils -import java.util.Formatter -import java.util.Locale -import javax.inject.Inject - -/** - * View containing controls for a MediaPlayer. - * - * - * Holds buttons "play / pause", "rewind", "fast forward" and a progress slider. - * - * - * It synchronizes itself with the state of the MediaPlayer. - */ -class MediaControlView(context: Context, attrs: AttributeSet?) : - LinearLayout(context, attrs), - View.OnClickListener, - OnSeekBarChangeListener { - - private var playerControl: Player? = null - private var binding: MediaControlBinding - private var isDragging = false - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - public override fun onFinishInflate() { - super.onFinishInflate() - } - - @Suppress("MagicNumber") - fun setMediaPlayer(player: Player?) { - playerControl = player - handler.sendEmptyMessage(SHOW_PROGRESS) - - handler.postDelayed({ - updatePausePlay() - setProgress() - }, 100) - } - - @Suppress("MagicNumber") - private fun initControllerView() { - binding.playBtn.requestFocus() - - binding.playBtn.setOnClickListener(this) - binding.forwardBtn.setOnClickListener(this) - binding.rewindBtn.setOnClickListener(this) - - binding.progressBar.run { - viewThemeUtils.platform.themeHorizontalSeekBar(this) - setMax(1000) - } - - binding.progressBar.setOnSeekBarChangeListener(this) - - viewThemeUtils.material.run { - colorMaterialButtonPrimaryTonal(binding.rewindBtn) - colorMaterialButtonPrimaryTonal(binding.playBtn) - colorMaterialButtonPrimaryTonal(binding.forwardBtn) - } - } - - /** - * Disable pause or seek buttons if the stream cannot be paused or seeked. - * This requires the control interface to be a MediaPlayerControlExt - */ - private fun disableUnsupportedButtons() { - try { - if (playerControl?.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)?.not() == true) { - binding.playBtn.isEnabled = false - } - - if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK)?.not() == true) { - binding.rewindBtn.isEnabled = false - } - if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD)?.not() == true) { - binding.forwardBtn.isEnabled = false - } - } catch (ex: IncompatibleClassChangeError) { - // We were given an old version of the interface, that doesn't have - // the canPause/canSeekXYZ methods. This is OK, it just means we - // assume the media can be paused and seeked, and so we don't disable - // the buttons. - Log_OC.i(TAG, "Old media interface detected") - } - } - - @Suppress("MagicNumber") - private val handler: Handler = object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - if (msg.what == SHOW_PROGRESS) { - updatePausePlay() - val pos = setProgress() - - if (!isDragging) { - sendMessageDelayed(obtainMessage(SHOW_PROGRESS), (1000 - pos % 1000)) - } - } - } - } - - init { - MainApp.getAppComponent().inject(this) - - val inflate = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - binding = MediaControlBinding.inflate(inflate, this, true) - initControllerView() - isFocusable = true - setFocusableInTouchMode(true) - setDescendantFocusability(FOCUS_AFTER_DESCENDANTS) - requestFocus() - } - - @Suppress("MagicNumber") - private fun formatTime(timeMs: Long): String { - val totalSeconds = timeMs / 1000 - val seconds = totalSeconds % 60 - val minutes = totalSeconds / 60 % 60 - val hours = totalSeconds / 3600 - val mFormatBuilder = StringBuilder() - val mFormatter = Formatter(mFormatBuilder, Locale.getDefault()) - return if (hours > 0) { - mFormatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() - } else { - mFormatter.format("%02d:%02d", minutes, seconds).toString() - } - } - - @Suppress("MagicNumber") - private fun setProgress(): Long { - var position = 0L - if (playerControl == null || isDragging) { - position = 0 - } - - playerControl?.let { playerControl -> - position = playerControl.currentPosition - val duration = playerControl.duration - if (duration > 0) { - // use long to avoid overflow - val pos = 1000L * position / duration - binding.progressBar.progress = pos.toInt() - } - val percent = playerControl.bufferedPercentage - binding.progressBar.setSecondaryProgress(percent * 10) - val endTime = if (duration > 0) formatTime(duration) else "--:--" - binding.totalTimeText.text = endTime - binding.currentTimeText.text = formatTime(position) - } - - return position - } - - @Suppress("ReturnCount") - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val keyCode = event.keyCode - val uniqueDown = (event.repeatCount == 0 && event.action == KeyEvent.ACTION_DOWN) - - when (keyCode) { - KeyEvent.KEYCODE_HEADSETHOOK, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_SPACE -> { - if (uniqueDown) { - doPauseResume() - // show(sDefaultTimeout); - binding.playBtn.requestFocus() - } - return true - } - - KeyEvent.KEYCODE_MEDIA_PLAY -> { - if (uniqueDown && playerControl?.playWhenReady == false) { - playerControl?.play() - updatePausePlay() - } - return true - } - - KeyEvent.KEYCODE_MEDIA_STOP, - KeyEvent.KEYCODE_MEDIA_PAUSE - -> { - if (uniqueDown && playerControl?.playWhenReady == true) { - playerControl?.pause() - updatePausePlay() - } - return true - } - - else -> return super.dispatchKeyEvent(event) - } - } - - fun updatePausePlay() { - binding.playBtn.icon = ContextCompat.getDrawable( - context, - // use isPlaying instead of playWhenReady - // it represents only the play/pause state - // which is needed to show play/pause icons - if (playerControl?.isPlaying == true) { - R.drawable.ic_pause - } else { - R.drawable.ic_play - } - ) - binding.forwardBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD) == true) { - VISIBLE - } else { - INVISIBLE - } - binding.rewindBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK) == true) { - VISIBLE - } else { - INVISIBLE - } - } - - private fun doPauseResume() { - playerControl?.run { - if (playWhenReady) { - pause() - } else { - play() - } - } - updatePausePlay() - } - - override fun setEnabled(enabled: Boolean) { - binding.playBtn.setEnabled(enabled) - binding.forwardBtn.setEnabled(enabled) - binding.rewindBtn.setEnabled(enabled) - binding.progressBar.setEnabled(enabled) - - disableUnsupportedButtons() - - super.setEnabled(enabled) - } - - @Suppress("MagicNumber") - override fun onClick(v: View) { - playerControl?.let { playerControl -> - val playing = playerControl.playWhenReady - val id = v.id - - when (id) { - R.id.playBtn -> { - doPauseResume() - } - - R.id.rewindBtn -> { - playerControl.seekBack() - if (!playing) { - playerControl.pause() // necessary in some 2.3.x devices - } - setProgress() - } - - R.id.forwardBtn -> { - playerControl.seekForward() - if (!playing) { - playerControl.pause() // necessary in some 2.3.x devices - } - - setProgress() - } - - else -> { - } - } - } - } - - @Suppress("MagicNumber") - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (!fromUser) { - // We're not interested in programmatically generated changes to - // the progress bar's position. - return - } - - playerControl?.let { playerControl -> - val duration = playerControl.duration - val newPosition = duration * progress / 1000L - playerControl.seekTo(newPosition) - binding.currentTimeText.text = formatTime(newPosition) - } - } - - /** - * Called in devices with touchpad when the user starts to adjust the position of the seekbar's thumb. - * - * Will be followed by several onProgressChanged notifications. - */ - override fun onStartTrackingTouch(seekBar: SeekBar) { - isDragging = true // monitors the duration of dragging - handler.removeMessages(SHOW_PROGRESS) // grants no more updates with media player progress while dragging - } - - /** - * Called in devices with touchpad when the user finishes the adjusting of the seekbar. - */ - override fun onStopTrackingTouch(seekBar: SeekBar) { - isDragging = false - setProgress() - updatePausePlay() - handler.sendEmptyMessage(SHOW_PROGRESS) // grants future updates with media player progress - } - - override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) { - super.onInitializeAccessibilityEvent(event) - event.setClassName(MediaControlView::class.java.getName()) - } - - override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { - super.onInitializeAccessibilityNodeInfo(info) - info.setClassName(MediaControlView::class.java.getName()) - } - - companion object { - private val TAG = MediaControlView::class.java.getSimpleName() - private const val SHOW_PROGRESS = 1 - } -} diff --git a/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java index 797de413eca6..80ddd5f02a00 100644 --- a/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java +++ b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java @@ -35,7 +35,9 @@ import com.owncloud.android.utils.MimeType; import java.util.ArrayList; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; import javax.inject.Inject; @@ -81,6 +83,9 @@ public class FileContentProvider extends ContentProvider { private static final String[] PROJECTION_FILE_PATH_AND_OWNER = new String[]{ ProviderTableMeta._ID, ProviderTableMeta.FILE_PATH, ProviderTableMeta.FILE_ACCOUNT_OWNER }; + private static final String[] PROJECTION_PARENT_ID = new String[]{ + ProviderTableMeta._ID, ProviderTableMeta.FILE_PARENT + }; @Inject protected Clock clock; @@ -96,15 +101,21 @@ public int delete(@NonNull Uri uri, String where, String[] whereArgs) { } int count; + Set parentIds; SupportSQLiteDatabase db = mDbHelper.getWritableDatabase(); db.beginTransaction(); try { + parentIds = queryParentIds(db, uri, where, whereArgs); count = delete(db, uri, where, whereArgs); db.setTransactionSuccessful(); } finally { db.endTransaction(); } mContext.getContentResolver().notifyChange(uri, null); + for (long parentId : parentIds) { + Uri parentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, parentId); + mContext.getContentResolver().notifyChange(parentUri, null); + } return count; } @@ -218,6 +229,11 @@ public Uri insert(@NonNull Uri uri, ContentValues values) { db.endTransaction(); } mContext.getContentResolver().notifyChange(newUri, null); + Long parentId = values.getAsLong(ProviderTableMeta.FILE_PARENT); + if (parentId != null) { + Uri parentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, parentId); + mContext.getContentResolver().notifyChange(parentUri, null); + } return newUri; } @@ -556,15 +572,21 @@ public int update(@NonNull Uri uri, ContentValues values, String selection, Stri } int count; + Set parentIds; SupportSQLiteDatabase db = mDbHelper.getWritableDatabase(); db.beginTransaction(); try { + parentIds = queryParentIds(db, uri, selection, selectionArgs); count = update(db, uri, values, selection, selectionArgs); db.setTransactionSuccessful(); } finally { db.endTransaction(); } mContext.getContentResolver().notifyChange(uri, null); + for (long parentId : parentIds) { + Uri parentUri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_DIR, parentId); + mContext.getContentResolver().notifyChange(parentUri, null); + } return count; } @@ -595,6 +617,27 @@ private int update(SupportSQLiteDatabase db, Uri uri, ContentValues values, Stri }; } + private Set queryParentIds(SupportSQLiteDatabase db, Uri uri, String where, String... whereArgs) { + Set result = new HashSet<>(); + int uriMatch = mUriMatcher.match(uri); + if (uriMatch == ROOT_DIRECTORY || mUriMatcher.match(uri) == DIRECTORY || mUriMatcher.match(uri) == SINGLE_FILE) { + try (Cursor cursor = query(db, uri, PROJECTION_PARENT_ID, where, whereArgs, null)) { + if (cursor.moveToFirst()) { + do { + int parentIdColumnIndex = cursor.getColumnIndex(ProviderTableMeta.FILE_PARENT); + if (parentIdColumnIndex != -1 && !cursor.isNull(parentIdColumnIndex)) { + long parentId = cursor.getLong(parentIdColumnIndex); + result.add(parentId); + } + } while (cursor.moveToNext()); + } + } catch (Exception e) { + Log_OC.d(TAG, "Error querying parent IDs", e); + } + } + return result; + } + @NonNull @Override public ContentProviderResult[] applyBatch(@NonNull ArrayList operations) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index dd932f9f64ee..e8c8afce4a73 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -57,6 +57,7 @@ import com.nextcloud.client.files.DeepLinkConstants; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.onboarding.FirstRunActivity; +import com.nextcloud.client.player.media3.PlaybackModel; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.common.NextcloudClient; import com.nextcloud.ui.ChooseAccountDialogFragment; @@ -149,6 +150,9 @@ public abstract class DrawerActivity extends ToolbarActivity public static final int REQ_ALL_FILES_ACCESS = 3001; public static final int REQ_MEDIA_ACCESS = 3000; + @Inject + protected PlaybackModel playbackModel; + /** * Reference to the drawer layout. */ @@ -728,6 +732,7 @@ public void openManageAccounts() { } public void openAddAccount() { + stopMediaPlayerAndHidePip(); if (MDMConfig.INSTANCE.showIntro(this)) { Intent firstRunIntent = new Intent(getApplicationContext(), FirstRunActivity.class); firstRunIntent.putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true); @@ -737,6 +742,10 @@ public void openAddAccount() { } } + protected void stopMediaPlayerAndHidePip() { + playbackModel.release(); + } + private void resetFileDepth() { final var ocFileListFragment = getOCFileListFragment(); if (ocFileListFragment != null) { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java index 2569f38154ba..d22d2a8b902d 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java @@ -37,6 +37,7 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.client.network.NetworkChangeListener; +import com.nextcloud.client.player.ui.PlayerActivity; import com.nextcloud.utils.EditorUtils; import com.nextcloud.utils.extensions.ActivityExtensionsKt; import com.nextcloud.utils.extensions.BundleExtensionsKt; @@ -92,7 +93,6 @@ import com.owncloud.android.ui.fragment.filesRepository.RemoteFilesRepository; import com.owncloud.android.ui.helpers.FileOperationsHelper; import com.owncloud.android.ui.preview.PreviewImageActivity; -import com.owncloud.android.ui.preview.PreviewMediaActivity; import com.owncloud.android.utils.ClipboardUtil; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.ErrorMessageAdapter; @@ -252,7 +252,7 @@ public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailab refreshList(); } } else { - if (this instanceof PreviewMediaActivity) { + if (this instanceof PlayerActivity) { hideInfoBox(); } else { showInfoBox(R.string.offline_mode); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 51a47f0c0c48..89faffefe038 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -71,8 +71,8 @@ import com.nextcloud.client.jobs.folderDownload.FolderDownloadEventBroadcaster import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker -import com.nextcloud.client.media.PlayerServiceConnection import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.player.ui.PlayerLauncher import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.utils.IntentUtil import com.nextcloud.model.WorkerState.OfflineOperationsCompleted @@ -143,9 +143,6 @@ import com.owncloud.android.ui.interfaces.TransactionInterface import com.owncloud.android.ui.navigation.NavigatorScreen import com.owncloud.android.ui.preview.PreviewImageActivity import com.owncloud.android.ui.preview.PreviewImageFragment -import com.owncloud.android.ui.preview.PreviewMediaActivity -import com.owncloud.android.ui.preview.PreviewMediaFragment -import com.owncloud.android.ui.preview.PreviewMediaFragment.Companion.newInstance import com.owncloud.android.ui.preview.PreviewTextFileFragment import com.owncloud.android.ui.preview.PreviewTextFragment import com.owncloud.android.ui.preview.PreviewTextStringFragment @@ -225,7 +222,6 @@ class FileDisplayActivity : private var searchOpen = false private var searchView: SearchView? = null - private var mPlayerConnection: PlayerServiceConnection? = null private var lastDisplayedAccountName: String? = null // needed for first time app launch multiple listing directory call @@ -233,6 +229,9 @@ class FileDisplayActivity : // causing also empty state thus this flag is used. private var listFragmentJustCreated = false + @Inject + lateinit var playerLauncher: PlayerLauncher + @Inject lateinit var localBroadcastManager: LocalBroadcastManager @@ -302,10 +301,7 @@ class FileDisplayActivity : showSortListGroup(savedInstanceState.getBoolean(KEY_IS_SORT_GROUP_VISIBLE)) } - mPlayerConnection = PlayerServiceConnection(this) - checkStoragePath() - observeWorkerState() startMetadataSyncForRoot() handleBackPress() @@ -865,6 +861,9 @@ class FileDisplayActivity : } } + fun canMediaPreviewed(file: OCFile?): Boolean = + file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) + private fun tryStartWaitingPreview(success: Boolean): Boolean { if (!success) return false @@ -877,8 +876,8 @@ class FileDisplayActivity : true } - PreviewMediaActivity.canBePreviewed(file) -> { - startMediaPreview(file, 0, true, true, true, true) + canMediaPreviewed(file) -> { + startMediaPreview(file, true, true) true } @@ -2050,12 +2049,9 @@ class FileDisplayActivity : } else if (PreviewTextFileFragment.canBePreviewed(file)) { setFabVisible?.onComplete(false) startTextPreview(file, false) - } else if (MimeTypeUtil.isVideo(file)) { - setFabVisible?.onComplete(false) - startImagePreview(file, true) - } else if (PreviewMediaActivity.Companion.canBePreviewed(file)) { + } else if (canMediaPreviewed(file)) { setFabVisible?.onComplete(false) - startMediaPreview(file, 0, true, true, false, true) + startMediaPreview(file, true, false) } else { fileOperationsHelper.openFile(file) } @@ -2204,7 +2200,7 @@ class FileDisplayActivity : } val removedFile = operation.file - tryStopPlaying(removedFile) + file?.let { playbackModel.stopPlaying(it) } val leftFragment = this.leftFragment // check if file is still available, if so do nothing @@ -2309,13 +2305,6 @@ class FileDisplayActivity : } } - private fun tryStopPlaying(file: OCFile) { - // placeholder for stop-on-delete future code - if (mPlayerConnection != null && MimeTypeUtil.isAudio(file) && mPlayerConnection?.isPlaying() == true) { - mPlayerConnection?.stop(file) - } - } - /** * Updates the view associated to the activity after the finish of an operation trying to move a file. * @@ -2415,22 +2404,6 @@ class FileDisplayActivity : showDetails(ocFile) } - is PreviewMediaFragment -> { - fragment.updateFile(ocFile) - if (PreviewMediaFragment.isAudioOrVideo(ocFile)) { - startMediaPreview( - ocFile, - fragment.position, - true, - true, - true, - false - ) - } else { - fileOperationsHelper.openFile(ocFile) - } - } - is PreviewTextFileFragment -> { fragment.updateFile(ocFile) if (PreviewTextFileFragment.canBePreviewed(ocFile)) { @@ -2695,56 +2668,28 @@ class FileDisplayActivity : } /** - * Stars the preview of an already down media [OCFile]. - * - * @param file Media [OCFile] to preview. - * @param startPlaybackPosition Media position where the playback will be started, in milliseconds. - * @param autoplay When 'true', the playback will start without user interactions. + * Starts the preview of a media [OCFile], synchronizing it first when it is not available yet. */ - fun startMediaPreview( - file: OCFile, - startPlaybackPosition: Long, - autoplay: Boolean, - showPreview: Boolean, - streamMedia: Boolean, - showInActivity: Boolean - ) { + fun startMediaPreview(file: OCFile, showPreview: Boolean, streamMedia: Boolean) { val user = getUser() if (!user.isPresent) { return // not reachable under normal conditions } - val actualUser = user.get() if ((showPreview && file.isDown && !file.isDownloading) || streamMedia) { - if (showInActivity) { - startMediaActivity(file, startPlaybackPosition, autoplay, actualUser) - } else { - configureToolbarForPreview(file) - val mediaFragment: Fragment = newInstance(file, user.get(), startPlaybackPosition, autoplay) - setLeftFragment(mediaFragment, false) - } + startMediaActivity(file) } else { val previewIntent = Intent() previewIntent.putExtra(EXTRA_FILE, file) - previewIntent.putExtra(PreviewMediaFragment.EXTRA_START_POSITION, startPlaybackPosition) - previewIntent.putExtra(PreviewMediaFragment.EXTRA_AUTOPLAY, autoplay) + previewIntent.putExtra(MEDIA_PREVIEW, true) val fileOperationsHelper = FileOperationsHelper(this, userAccountManager, connectivityService, editorUtils) fileOperationsHelper.startSyncForFileAndIntent(file, previewIntent) } } - private fun startMediaActivity(file: OCFile?, startPlaybackPosition: Long, autoplay: Boolean, user: User?) { - val previewMediaIntent = Intent(this, PreviewMediaActivity::class.java) - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_FILE, file) - - // Safely handle the absence of a user - if (user != null) { - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_USER, user) - } - - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_START_POSITION, startPlaybackPosition) - previewMediaIntent.putExtra(PreviewMediaActivity.EXTRA_AUTOPLAY, autoplay) - startActivity(previewMediaIntent) + private fun startMediaActivity(file: OCFile) { + val searchType = listOfFilesFragment?.currentSearchType + playerLauncher.launch(this, file, searchType) } fun configureToolbarForPreview(file: OCFile?) { @@ -2937,17 +2882,8 @@ class FileDisplayActivity : if (event.intent.getBooleanExtra(TEXT_PREVIEW, false)) { startTextPreview(file, true) - } else if (bundle.containsKey(PreviewMediaFragment.EXTRA_START_POSITION)) { - val startPosition = bundle.get(PreviewMediaFragment.EXTRA_START_POSITION) as Long - val autoPlay = bundle.get(PreviewMediaFragment.EXTRA_AUTOPLAY) as Boolean - startMediaPreview( - file, - startPosition, - autoPlay, - true, - true, - true - ) + } else if (event.intent.getBooleanExtra(MEDIA_PREVIEW, false)) { + startMediaPreview(file, true, true) } else if (bundle.containsKey(PreviewImageActivity.EXTRA_VIRTUAL_TYPE)) { val virtualType = bundle.get(PreviewImageActivity.EXTRA_VIRTUAL_TYPE) as VirtualFolderType? startImagePreview( @@ -3332,6 +3268,7 @@ class FileDisplayActivity : const val TAG_LIST_OF_FILES: String = "LIST_OF_FILES" const val TEXT_PREVIEW: String = "TEXT_PREVIEW" + const val MEDIA_PREVIEW: String = "MEDIA_PREVIEW" const val KEY_IS_SEARCH_OPEN: String = "IS_SEARCH_OPEN" const val KEY_SEARCH_QUERY: String = "SEARCH_QUERY" diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt index 47de3ea71b76..0f383ae5183e 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt @@ -240,6 +240,7 @@ class ManageAccountsActivity : } override fun showFirstRunActivity() { + stopMediaPlayerAndHidePip() val intent = Intent(applicationContext, FirstRunActivity::class.java).apply { putExtra(FirstRunActivity.EXTRA_ALLOW_CLOSE, true) } @@ -249,6 +250,7 @@ class ManageAccountsActivity : @Suppress("TooGenericExceptionCaught") @SuppressLint("NotifyDataSetChanged") override fun startAccountCreation() { + stopMediaPlayerAndHidePip() val am = AccountManager.get(applicationContext) am.addAccount( MainApp.getAccountType(this), diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt index 615b71543fa6..f586ed12ef2f 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt @@ -9,10 +9,12 @@ package com.owncloud.android.ui.adapter import android.view.View import android.widget.TextView +import com.nextcloud.client.player.ui.PlayerProgressIndicator internal interface ListGridItemViewHolder : ListViewHolder { val fileName: TextView val extension: TextView? + val playerProgressIndicator: PlayerProgressIndicator? get() = null val bidiFilename: TextView val bidiFilenameContainer: View } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java index e164a0da1688..26a170aaab42 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -551,6 +551,11 @@ private void setFilenameAndExtension(ListGridItemViewHolder holder, OCFile file) } else { handleListMode(holder, filename, pair); } + + final var playerProgressIndicator = holder.getPlayerProgressIndicator(); + if (playerProgressIndicator != null) { + playerProgressIndicator.setFile(file); + } } private void handleGridMode(String filename, OCFileListGridItemViewHolder holder, Pair filenamePair, OCFile file) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt index 351c9258583b..65aaeb970944 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt @@ -15,6 +15,7 @@ import android.widget.TextView import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.nextcloud.client.player.ui.PlayerProgressIndicator import com.owncloud.android.databinding.GridItemBinding class OCFileListGridItemViewHolder(var binding: GridItemBinding) : @@ -34,6 +35,8 @@ class OCFileListGridItemViewHolder(var binding: GridItemBinding) : } else { null } + override val playerProgressIndicator: PlayerProgressIndicator + get() = binding.playerProgressIndicator override val thumbnail: ImageView get() = binding.thumbnail diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt index 919a394911d6..0bc522560bf6 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt @@ -16,6 +16,7 @@ import androidx.recyclerview.widget.RecyclerView import com.elyeproj.loaderviewlibrary.LoaderImageView import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup +import com.nextcloud.client.player.ui.PlayerProgressIndicator import com.owncloud.android.databinding.ListItemBinding import com.owncloud.android.ui.AvatarGroupLayout @@ -32,7 +33,8 @@ class OCFileListItemViewHolder(private var binding: ListItemBinding) : get() = binding.livePhotoIndicatorSeparator override val hasVisibleFeatureIndicators: Boolean get() = false - + override val playerProgressIndicator: PlayerProgressIndicator + get() = binding.playerProgressIndicator override val fileSize: TextView get() = binding.fileSize override val fileSizeSeparator: View diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt index cd80aa84a0b7..9166d438168b 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt @@ -122,6 +122,7 @@ object OCShareToOCFileConverter { note = firstShare.note fileId = firstShare.fileSource remoteId = firstShare.remoteId.toString() + localId = firstShare.fileSource this.firstShareTimestamp = firstShareTimestamp isFavorite = firstShare.isFavorite } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt index 595e092e0de8..4a9063545718 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/AccountRemovalDialog.kt @@ -19,6 +19,7 @@ import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.player.media3.PlaybackModel import com.nextcloud.utils.extensions.getParcelableArgument import com.owncloud.android.R import com.owncloud.android.databinding.AccountRemovalDialogBinding @@ -40,6 +41,9 @@ class AccountRemovalDialog : @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var playbackModel: PlaybackModel + private var user: User? = null private lateinit var alertDialog: AlertDialog private var _binding: AccountRemovalDialogBinding? = null @@ -125,6 +129,7 @@ class AccountRemovalDialog : */ private fun removeAccount() { user?.let { user -> + stopMediaPlayerAndHidePip() if (binding.radioRequestDeletion.isChecked) { DisplayUtils.startLinkIntent(activity, user.server.uri.toString() + DROP_ACCOUNT_URI) } else { @@ -133,6 +138,10 @@ class AccountRemovalDialog : } } + private fun stopMediaPlayerAndHidePip() { + playbackModel.release() + } + /** * Start avatar generation. */ diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 0945960e09fc..9a80cdf078b9 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -115,13 +115,11 @@ import com.owncloud.android.ui.helpers.FileOperationsHelper; import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface; import com.owncloud.android.ui.preview.PreviewImageFragment; -import com.owncloud.android.ui.preview.PreviewMediaActivity; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.EncryptionUtilsV2; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; -import com.owncloud.android.utils.MimeTypeUtil; import com.owncloud.android.utils.PermissionUtil; import com.owncloud.android.utils.WebViewUtil; import com.owncloud.android.utils.theme.ThemeUtils; @@ -156,7 +154,6 @@ import static com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG; import static com.owncloud.android.ui.fragment.SearchType.FAVORITE_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.FILE_SEARCH; -import static com.owncloud.android.ui.fragment.SearchType.GALLERY_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.NO_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.RECENT_FILES_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.SHARED_FILTER; @@ -1199,7 +1196,7 @@ private void fileOnItemClick(OCFile file) { return; } - if (canPreviewInVirtualFolderPager(file) && mContainerActivity instanceof FileDisplayActivity fda) { + if (PreviewImageFragment.canBePreviewed(file) && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewImageWithSearchContext(file, searchFragment, currentSearchType); } else if (file.isDown() && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewFile(file, this::setFabVisible); @@ -1208,20 +1205,6 @@ private void fileOnItemClick(OCFile file) { } } - /** - * In a gallery or favorites search the preview pager is built from the whole virtual folder, so a directly - * tapped video must open through the same pager as the images. - */ - private boolean canPreviewInVirtualFolderPager(OCFile file) { - if (PreviewImageFragment.canBePreviewed(file)) { - return true; - } - - boolean virtualFolderSearch = searchFragment - && (currentSearchType == GALLERY_SEARCH || currentSearchType == FAVORITE_SEARCH); - return virtualFolderSearch && MimeTypeUtil.isVideo(file); - } - private boolean supportsDirectEditing(OCFile file, boolean webViewAvailable) { if (!webViewAvailable) { return false; @@ -1250,12 +1233,9 @@ private void handlePendingDownloadFile(OCFile file) { boolean webViewAvailable = WebViewUtil.available(getContext()); - if (MimeTypeUtil.isVideo(file) && !file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda) { - setFabVisible(false); - fda.startImagePreview(file, true, null); - } else if (PreviewMediaActivity.Companion.canBePreviewed(file) && !file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda) { + if (!file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda && fda.canMediaPreviewed(file)) { setFabVisible(false); - fda.startMediaPreview(file, 0, true, true, true, true); + fda.startMediaPreview(file, true, true); } else if (webViewAvailable && editorUtils.getEditor(accountManager.getUser(), file.getMimeType()) != null && !file.isEncrypted()) { TextEditorWebView.Companion.startTextEditor(file, getContext()); } else if (supportsDirectEditing(file, webViewAvailable)) { diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index 86d326d5c6ae..f0f7b50f4133 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -409,7 +409,7 @@ public void streamMediaFile(OCFile file) { final User user = currentAccount.getUser(); new Thread(() -> { StreamMediaFileOperation sfo = new StreamMediaFileOperation(file.getLocalId()); - RemoteOperationResult result = sfo.execute(user, fileActivity); + final var result = sfo.execute(user, fileActivity); fileActivity.dismissLoadingDialog(); diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index e180a0cdef07..71895ff2ad5c 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -65,7 +65,6 @@ import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.ui.preview.PreviewMediaFragment.Companion.newInstance import com.owncloud.android.utils.BitmapUtils import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil @@ -159,16 +158,11 @@ class PreviewImageFragment : } } - private fun playLivePhoto(file: OCFile?) { - if (file == null) { - return - } - + private fun playLivePhoto(file: OCFile) { hideActionBar() - val mediaFragment: Fragment = newInstance(file, accountManager.user, autoplay = true, isLivePhoto = true) - val fragmentManager = requireActivity().supportFragmentManager - fragmentManager.beginTransaction().run { + val mediaFragment = PreviewPlaybackFragment.newInstance(file, searchType = null, autoplay = true) + requireActivity().supportFragmentManager.beginTransaction().run { replace(R.id.top, mediaFragment) addToBackStack(null) commit() diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt deleted file mode 100644 index 55b653f5e46d..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ /dev/null @@ -1,856 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2023 Alper Ozturk - * SPDX-FileCopyrightText: 2023 TSI-mc - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2016 David A. Velasco - * SPDX-FileCopyrightText: 2016 ownCloud Inc. - * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) - */ -package com.owncloud.android.ui.preview - -import android.content.ComponentName -import android.content.DialogInterface -import android.content.Intent -import android.content.res.Configuration -import android.graphics.BitmapFactory -import android.graphics.Color -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.AsyncTask -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.annotation.OptIn -import androidx.annotation.StringRes -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.DrawableCompat -import androidx.core.graphics.drawable.toDrawable -import androidx.core.net.toUri -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.marginBottom -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaController -import androidx.media3.session.MediaSession -import androidx.media3.session.SessionToken -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.MoreExecutors -import com.nextcloud.client.account.User -import com.nextcloud.client.di.Injectable -import com.nextcloud.client.jobs.download.FileDownloadHelper -import com.nextcloud.client.jobs.download.SendShareDownloader -import com.nextcloud.client.media.BackgroundPlayerService -import com.nextcloud.client.media.ErrorFormat -import com.nextcloud.client.media.ExoplayerListener -import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.client.network.ClientFactory.CreationException -import com.nextcloud.common.NextcloudClient -import com.nextcloud.ui.fileactions.FileAction -import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance -import com.nextcloud.ui.fileactions.FileActionsBottomSheet.ResultListener -import com.nextcloud.utils.extensions.getParcelableArgument -import com.nextcloud.utils.extensions.logFileSize -import com.nextcloud.utils.extensions.setFullscreenButton -import com.nextcloud.utils.extensions.setTitleColor -import com.owncloud.android.R -import com.owncloud.android.databinding.ActivityPreviewMediaBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.files.StreamMediaFileOperation -import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.operations.OnRemoteOperationListener -import com.owncloud.android.lib.common.operations.RemoteOperation -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.operations.DownloadType -import com.owncloud.android.operations.RemoveFileOperation -import com.owncloud.android.operations.SynchronizeFileOperation -import com.owncloud.android.ui.activity.FileActivity -import com.owncloud.android.ui.activity.FileDisplayActivity -import com.owncloud.android.ui.dialog.ConfirmationDialogFragment -import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment -import com.owncloud.android.ui.dialog.SendShareDialog -import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.ErrorMessageAdapter -import com.owncloud.android.utils.MimeTypeUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.lang.ref.WeakReference - -/** - * This activity shows a preview of a downloaded media file (audio or video). - * - * - * Trying to get an instance with NULL [OCFile] or ownCloud [User] values will produce an [ ]. - * - * - * By now, if the [OCFile] passed is not downloaded, an [IllegalStateException] is generated on - * instantiation too. - */ -@Suppress("TooManyFunctions") -@OptIn(UnstableApi::class) -class PreviewMediaActivity : - FileActivity(), - FileFragment.ContainerActivity, - OnRemoteOperationListener, - SendShareDialog.SendShareDialogDownloader, - Injectable { - - private var user: User? = null - private var savedPlaybackPosition: Long = 0 - private var autoplay = true - private var streamUri: Uri? = null - private var nextcloudClient: NextcloudClient? = null - - private val sendShareDownloader by lazy { SendShareDownloader(this) } - - private lateinit var binding: ActivityPreviewMediaBinding - - private val exoplayerView: PlayerView - get() = binding.exoplayerView.root - - private var emptyListView: ViewGroup? = null - private var videoPlayer: ExoPlayer? = null - private var videoMediaSession: MediaSession? = null - private var audioMediaController: MediaController? = null - private var mediaControllerFuture: ListenableFuture? = null - private var fullscreenDialog: PreviewVideoFullscreenDialog? = null - private lateinit var windowInsetsController: WindowInsetsControllerCompat - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding = ActivityPreviewMediaBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.materialToolbar) - WindowCompat.setDecorFitsSystemWindows(window, false) - applyWindowInsets() - initArguments(savedInstanceState) - if (MimeTypeUtil.isVideo(file)) { - // release any background media session if exists - sendAudioSessionReleaseBroadcast() - } else if (MimeTypeUtil.isAudio(file)) { - val stopPlayer = Intent(BackgroundPlayerService.STOP_MEDIA_SESSION_BROADCAST_ACTION).apply { - setPackage(packageName) - } - sendBroadcast(stopPlayer) - } - - showMediaTypeViews() - configureSystemBars() - emptyListView = binding.emptyView.emptyListView - showProgressLayout() - - lifecycle.addObserver(sendShareDownloader) - sendShareDownloader.restoreState(savedInstanceState) - - if (file == null) { - return - } - if (MimeTypeUtil.isAudio(file)) { - setGenericThumbnail() - initializeAudioPlayer() - } - } - - private fun sendAudioSessionReleaseBroadcast() { - val intent = Intent(BackgroundPlayerService.RELEASE_MEDIA_SESSION_BROADCAST_ACTION).apply { - setPackage(packageName) - } - sendBroadcast(intent) - } - - private fun initArguments(savedInstanceState: Bundle?) { - intent?.let { - initWithIntent(it) - } - - if (savedInstanceState == null) { - checkNotNull(file) { "Instanced with a NULL OCFile" } - checkNotNull(user) { "Instanced with a NULL ownCloud Account" } - } else { - initWithBundle(savedInstanceState) - } - } - - private fun initWithIntent(intent: Intent) { - file = intent.getParcelableArgument(FILE, OCFile::class.java) - user = intent.getParcelableArgument(USER, User::class.java) - savedPlaybackPosition = intent.getLongExtra(PLAYBACK_POSITION, 0L) - autoplay = intent.getBooleanExtra(AUTOPLAY, true) - } - - private fun initWithBundle(bundle: Bundle) { - file = bundle.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - user = bundle.getParcelableArgument(EXTRA_USER, User::class.java) - savedPlaybackPosition = bundle.getInt(EXTRA_PLAY_POSITION).toLong() - autoplay = bundle.getBoolean(EXTRA_PLAYING) - } - - private fun showMediaTypeViews() { - if (file == null) { - return - } - - exoplayerView.visibility = if (isFileVideo()) View.VISIBLE else View.GONE - binding.imagePreview.visibility = if (isFileVideo()) View.GONE else View.VISIBLE - - if (isFileVideo()) { - binding.root.setBackgroundColor(resources.getColor(R.color.black, null)) - } - } - - private fun isFileVideo(): Boolean = MimeTypeUtil.isVideo(file) - - private fun configureSystemBars() { - updateActionBarTitleAndHomeButton(file) - - supportActionBar?.let { - it.setDisplayHomeAsUpEnabled(true) - viewThemeUtils.files.themeActionBar(this, it) - - if (isFileVideo()) { - it.setTitleColor( - resources.getColor( - R.color.white, - null - ) - ) - - it.setHomeAsUpIndicator( - ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_back, theme) - ?.apply { setTint(Color.WHITE) } - ) - - it.setBackgroundDrawable(Color.BLACK.toDrawable()) - } - } - - viewThemeUtils.platform.themeStatusBar( - this - ) - } - - private fun showProgressLayout() { - binding.progress.visibility = View.VISIBLE - binding.audioControllerView.visibility = View.GONE - binding.emptyView.emptyListView.visibility = View.GONE - } - - private fun setErrorMessage(headline: String, @StringRes message: Int) { - binding.emptyView.run { - emptyListViewHeadline.text = headline - emptyListViewText.setText(message) - emptyListIcon.setImageResource(R.drawable.file_movie) - emptyListViewText.visibility = View.VISIBLE - emptyListIcon.visibility = View.VISIBLE - emptyListView.visibility = View.VISIBLE - } - - binding.progress.visibility = View.GONE - } - - private fun setGenericThumbnail() { - binding.imagePreview.setImageDrawable(genericThumbnail()) - } - - private fun genericThumbnail(): Drawable? { - val result = AppCompatResources.getDrawable(this, R.drawable.logo) - result?.let { - if (!resources.getBoolean(R.bool.is_branded_client)) { - DrawableCompat.setTint(it, resources.getColor(R.color.primary, this.theme)) - } - } - - return result - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - file.logFileSize(TAG) - outState.let { bundle -> - bundle.putParcelable(EXTRA_FILE, file) - bundle.putParcelable(EXTRA_USER, user) - saveMediaInstanceState(bundle) - } - sendShareDownloader.saveState(outState) - } - - private fun saveMediaInstanceState(bundle: Bundle) { - bundle.run { - if (MimeTypeUtil.isVideo(file)) { - videoPlayer?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - } - } else { - audioMediaController?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - } - } - putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition) - putBoolean(EXTRA_PLAYING, autoplay) - } - } - - override fun onStart() { - super.onStart() - Log_OC.v(TAG, "onStart") - if (MimeTypeUtil.isVideo(file) && videoPlayer == null) { - initializeVideoPlayer() - } - } - - private fun isFullscreenActive(): Boolean = fullscreenDialog?.isShowing == true - - private fun initializeVideoPlayer() { - lifecycleScope.launch(Dispatchers.IO) { - val client = clientRepository.getNextcloudClient() ?: return@launch - - withContext(Dispatchers.Main) { - nextcloudClient = client - videoPlayer = createNextcloudExoplayer(this@PreviewMediaActivity, client) - val uniqueSessionId = "preview_session_" + System.currentTimeMillis() - videoMediaSession = MediaSession.Builder(this@PreviewMediaActivity, videoPlayer as Player) - .setId(uniqueSessionId) - .build() - - videoPlayer?.run { - addListener( - ExoplayerListener( - this@PreviewMediaActivity, - exoplayerView, - this - ) - ) - - playVideo() - } - } - } - } - - private fun releaseVideoPlayer() { - videoPlayer?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - it.release() - videoMediaSession?.release() - } - videoMediaSession = null - videoPlayer = null - } - - @Suppress("TooGenericExceptionCaught") - private fun initializeAudioPlayer() { - val sessionToken = SessionToken(this, ComponentName(this, BackgroundPlayerService::class.java)) - mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() - mediaControllerFuture?.addListener( - { - try { - audioMediaController = mediaControllerFuture?.get() - playAudio() - binding.audioControllerView.setMediaPlayer(audioMediaController) - } catch (e: Exception) { - Log_OC.e(TAG, "exception raised while getting the media controller ${e.message}") - } - }, - MoreExecutors.directExecutor() - ) - } - - @Suppress("TooGenericExceptionCaught") - private fun playAudio() { - if (file?.isDown == true) { - prepareAudioPlayer(file?.storageUri) - } else { - try { - LoadStreamUrl(this, user, clientFactory).execute(file?.localId) - } catch (e: Exception) { - Log_OC.e(TAG, "Loading stream url for Audio not possible: $e") - } - } - } - - private fun prepareAudioPlayer(uri: Uri?) { - uri ?: return - audioMediaController?.let { audioPlayer -> - audioPlayer.addListener(object : Player.Listener { - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - if (playbackState == Player.STATE_READY) { - binding.progress.visibility = View.GONE - binding.audioControllerView.visibility = View.VISIBLE - binding.emptyView.emptyListView.visibility = View.GONE - } - } - - override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { - super.onMediaMetadataChanged(mediaMetadata) - val artworkBitmap = mediaMetadata.artworkData?.let { bytes: ByteArray -> - BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - } - if (artworkBitmap != null) { - binding.imagePreview.setImageBitmap(artworkBitmap) - } - } - - override fun onPlayerError(error: PlaybackException) { - super.onPlayerError(error) - Log_OC.e(TAG, "Exoplayer error", error) - val message = ErrorFormat.toString(this@PreviewMediaActivity, error) - MaterialAlertDialogBuilder(this@PreviewMediaActivity) - .setMessage(message) - .setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int -> - audioPlayer.seekToDefaultPosition() - audioPlayer.pause() - } - .setCancelable(false) - .show() - } - }) - val mediaItem = MediaItem.Builder() - .setUri(uri) - .setMediaMetadata(MediaMetadata.Builder().setTitle(file?.fileName).build()) - .build() - audioPlayer.setMediaItem(mediaItem) - audioPlayer.playWhenReady = autoplay - audioPlayer.seekTo(savedPlaybackPosition) - audioPlayer.prepare() - } - } - - private fun initWindowInsetsController() { - windowInsetsController = WindowCompat.getInsetsController( - window, - window.decorView - ).apply { - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - } - - private fun applyWindowInsets() { - val playerView = exoplayerView - val exoControls = playerView.findViewById(androidx.media3.ui.R.id.exo_bottom_bar) - val exoProgress = playerView.findViewById(androidx.media3.ui.R.id.exo_progress) - val progressBottomMargin = exoProgress.marginBottom - - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> - val insets = windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type - .displayCutout() - ) - - binding.materialToolbar.updateLayoutParams { - topMargin = insets.top - } - exoControls.updateLayoutParams { - bottomMargin = insets.bottom - } - exoProgress.updateLayoutParams { - bottomMargin = insets.bottom + progressBottomMargin - } - exoControls.updatePadding(left = insets.left, right = insets.right) - exoProgress.updatePadding(left = insets.left, right = insets.right) - binding.materialToolbar.updatePadding(left = insets.left, right = insets.right) - WindowInsetsCompat.CONSUMED - } - } - - private fun setupVideoView() { - initWindowInsetsController() - val type = WindowInsetsCompat.Type.systemBars() - exoplayerView.let { - it.setShowNextButton(false) - it.setShowPreviousButton(false) - it.setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visibility -> - if (visibility == View.VISIBLE) { - windowInsetsController.show(type) - supportActionBar?.show() - } else if (visibility == View.GONE) { - windowInsetsController.hide(type) - supportActionBar?.hide() - } - } - ) - it.player = videoPlayer - it.setFullscreenButton(isFullscreen = false) { startFullScreenVideo() } - } - } - - private fun startFullScreenVideo() { - val client = nextcloudClient ?: return - val player = videoPlayer ?: return - val dialog = PreviewVideoFullscreenDialog( - this, - client, - player, - exoplayerView - ) - .apply { - setOnDismissListener { - fullscreenDialog = null - setupVideoView() - } - } - - fullscreenDialog = dialog - dialog.show() - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.custom_menu_placeholder, menu) - - if (isFileVideo()) { - val moreMenuItem = menu?.findItem(R.id.custom_menu_placeholder_item) - moreMenuItem?.icon?.setTint(ContextCompat.getColor(this, R.color.white)) - } - - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - - if (item.itemId == R.id.custom_menu_placeholder_item) { - val file = file - - if (storageManager != null && file != null) { - val updatedFile = storageManager.getFileById(file.fileId) - setFile(updatedFile) - val fileNew = getFile() - fileNew?.let { showFileActions(it) } - } - } - - return super.onOptionsItemSelected(item) - } - - private fun showFileActions(file: OCFile) { - val additionalFilter = FileAction.getFilePreviewActions(getFile()) - newInstance(file, false, additionalFilter) - .setResultListener( - supportFragmentManager, - this, - object : ResultListener { - override fun onResult(actionId: Int) { - onFileActionChosen(actionId) - } - } - ) - .show(supportFragmentManager, "actions") - } - - private fun onFileActionChosen(itemId: Int) { - when (itemId) { - R.id.action_send_share_file -> { - sendShareFile(null) - } - - R.id.action_send_file -> { - sendShareFile(true) - } - - R.id.action_open_file_with -> { - openFile() - } - - R.id.action_remove_file -> { - videoPlayer?.pause() - val dialog = file?.let { RemoveFilesDialogFragment.newInstance(it) } - dialog?.show(supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) - } - - R.id.action_see_details -> { - seeDetails() - } - - R.id.action_sync_file -> { - showSyncLoadingDialog(file?.isFolder == true) - fileOperationsHelper.syncFileOrFolder(file) - } - - R.id.action_cancel_sync -> { - fileOperationsHelper.cancelTransference(file) - } - - R.id.action_stream_media -> { - fileOperationsHelper.streamMediaFile(file) - } - - R.id.action_export_file -> { - val list = ArrayList() - file?.let { list.add(it) } - fileOperationsHelper.exportFiles( - list, - this, - binding.root, - this.backgroundJobManager - ) - } - - R.id.action_download_file -> { - requestForDownload(file) - } - } - } - - override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>?) { - super.onRemoteOperationFinish(operation, result) - if (operation is RemoveFileOperation) { - if (result?.isSuccess == false) { - val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(result, operation, resources) - DisplayUtils.showSnackMessage(this, errorMessage) - } - - val removedFile = operation.file - val fileAvailable: Boolean = storageManager.fileExists(removedFile.fileId) - if (!fileAvailable && removedFile == file) { - sendAudioSessionReleaseBroadcast() - finish() - } - } else if (operation is SynchronizeFileOperation) { - onSynchronizeFileOperationFinish(result) - } - } - - private fun onSynchronizeFileOperationFinish(result: RemoteOperationResult<*>?) { - result?.let { - invalidateOptionsMenu() - } - } - - override fun downloadFile(file: OCFile, packageName: String, activityName: String) { - sendShareDownloader.downloadFile(file, packageName, activityName) - } - - private fun requestForDownload(file: OCFile?) { - val fileDownloadHelper = FileDownloadHelper.instance() - - if (fileDownloadHelper.isDownloading(user, file)) { - return - } - - user?.let { user -> - file?.let { file -> - fileDownloadHelper.downloadFile(user, file, downloadType = DownloadType.DOWNLOAD) - } - } - } - - private fun seeDetails() { - stopPreview(false) - showDetails(file) - } - - private fun sendShareFile(hideNCSharingOption: Boolean?) { - stopPreview(false) - - if (hideNCSharingOption != null) { - fileOperationsHelper.sendShareFile(file, hideNCSharingOption) - } else { - fileOperationsHelper.sendShareFile(file) - } - } - - @Suppress("TooGenericExceptionCaught") - private fun playVideo() { - setupVideoView() - - if (file?.isDown == true) { - prepareVideoPlayer(file?.storageUri) - } else { - try { - LoadStreamUrl(this, user, clientFactory).execute(file?.localId) - } catch (e: Exception) { - Log_OC.e(TAG, "Loading stream url for Video not possible: $e") - } - } - } - - private fun prepareVideoPlayer(uri: Uri?) { - uri ?: return - binding.progress.visibility = View.GONE - val videoMediaItem = MediaItem.fromUri(uri) - videoPlayer?.run { - setMediaItem(videoMediaItem) - playWhenReady = autoplay - seekTo(savedPlaybackPosition) - prepare() - } - } - - private class LoadStreamUrl( - previewMediaActivity: PreviewMediaActivity, - private val user: User?, - private val clientFactory: ClientFactory? - ) : AsyncTask() { - private val previewMediaActivityWeakReference: WeakReference = - WeakReference(previewMediaActivity) - - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg fileId: Long?): Uri? { - val client: OwnCloudClient? = try { - clientFactory?.create(user) - } catch (e: CreationException) { - Log_OC.e(TAG, "Loading stream url not possible: $e") - return null - } - - val sfo = StreamMediaFileOperation(fileId[0]!!) - val result = sfo.execute(client) - - return if (!result.isSuccess) { - null - } else { - (result.data[0] as String).toUri() - } - } - - @Deprecated("Deprecated in Java") - override fun onPostExecute(uri: Uri?) { - val weakReference = previewMediaActivityWeakReference.get() - weakReference?.apply { - if (uri != null) { - streamUri = uri - if (MimeTypeUtil.isVideo(file)) { - prepareVideoPlayer(uri) - } else if (MimeTypeUtil.isAudio(file)) { - prepareAudioPlayer(uri) - } - } else { - emptyListView?.visibility = View.VISIBLE - setErrorMessage( - weakReference.getString(R.string.stream_not_possible_headline), - R.string.stream_not_possible_message - ) - } - } - } - } - - override fun onPause() { - Log_OC.v(TAG, "onPause") - - super.onPause() - } - - override fun onResume() { - super.onResume() - - Log_OC.v(TAG, "onResume") - } - - override fun onDestroy() { - mediaControllerFuture?.let { MediaController.releaseFuture(it) } - releaseVideoPlayer() - super.onDestroy() - - Log_OC.v(TAG, "onDestroy") - } - - override fun onStop() { - Log_OC.v(TAG, "onStop") - if (!isFullscreenActive()) { - releaseVideoPlayer() - } - super.onStop() - } - - override fun showDetails(file: OCFile?) { - val intent = Intent(this, FileDisplayActivity::class.java).apply { - action = FileDisplayActivity.ACTION_DETAILS - putExtra(FileActivity.EXTRA_FILE, file) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - startActivity(intent) - finish() - } - - override fun showDetails(file: OCFile?, activeTab: Int) { - showDetails(file) - } - - override fun onBrowsedDownTo(folder: OCFile?) = Unit - override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) = Unit - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - Log_OC.v(TAG, "onConfigurationChanged $this") - } - - @Suppress("DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - Log_OC.v(TAG, "onActivityResult $this") - super.onActivityResult(requestCode, resultCode, data) - - if (resultCode == RESULT_OK) { - savedPlaybackPosition = data?.getLongExtra(EXTRA_START_POSITION, 0) ?: 0 - autoplay = data?.getBooleanExtra(EXTRA_AUTOPLAY, false) ?: false - } - } - - private fun openFile() { - stopPreview(true) - fileOperationsHelper.openFile(file) - } - - private fun stopPreview(stopAudio: Boolean) { - if (MimeTypeUtil.isAudio(file) && stopAudio) { - audioMediaController?.pause() - } else if (MimeTypeUtil.isVideo(file)) { - releaseVideoPlayer() - } - } - - companion object { - private val TAG = PreviewMediaActivity::class.java.simpleName - - const val MEDIA_CONTROL_READY_RECEIVER: String = "MEDIA_CONTROL_READY_RECEIVER" - const val EXTRA_FILE = "FILE" - const val EXTRA_USER = "USER" - const val EXTRA_AUTOPLAY = "AUTOPLAY" - const val EXTRA_START_POSITION = "START_POSITION" - private const val EXTRA_PLAY_POSITION = "PLAY_POSITION" - private const val EXTRA_PLAYING = "PLAYING" - private const val FILE = "FILE" - private const val USER = "USER" - private const val PLAYBACK_POSITION = "PLAYBACK_POSITION" - private const val AUTOPLAY = "AUTOPLAY" - - fun canBePreviewed(file: OCFile?): Boolean = - file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt deleted file mode 100644 index 0eed351e4acd..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ /dev/null @@ -1,613 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-FileCopyrightText: 2023 TSI-mc - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2016 ownCloud Inc. - * SPDX-FileCopyrightText: 2013 David A. Velasco - * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) - */ -package com.owncloud.android.ui.preview - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.content.res.Configuration -import android.content.res.Resources -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.MotionEvent -import android.view.View -import android.view.View.OnTouchListener -import android.view.ViewGroup -import androidx.annotation.OptIn -import androidx.annotation.StringRes -import androidx.core.net.toUri -import androidx.core.view.MenuHost -import androidx.core.view.MenuProvider -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.drawerlayout.widget.DrawerLayout -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaSession -import androidx.media3.ui.PlayerView -import com.nextcloud.client.account.User -import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.di.Injectable -import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.client.jobs.download.FileDownloadHelper.Companion.instance -import com.nextcloud.client.media.BackgroundPlayerService -import com.nextcloud.client.media.ExoplayerListener -import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer -import com.nextcloud.client.network.ClientFactory -import com.nextcloud.client.network.ClientFactory.CreationException -import com.nextcloud.common.NextcloudClient -import com.nextcloud.ui.fileactions.FileAction -import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance -import com.nextcloud.utils.extensions.applyControlsInsets -import com.nextcloud.utils.extensions.getParcelableArgument -import com.nextcloud.utils.extensions.getTypedActivity -import com.nextcloud.utils.extensions.setFullscreenButton -import com.owncloud.android.R -import com.owncloud.android.databinding.FragmentPreviewMediaBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.files.StreamMediaFileOperation -import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.ui.activity.DrawerActivity -import com.owncloud.android.ui.activity.FileActivity -import com.owncloud.android.ui.dialog.ConfirmationDialogFragment -import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment -import com.owncloud.android.ui.fragment.FileFragment -import com.owncloud.android.utils.MimeTypeUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -/** - * This fragment shows a preview of a downloaded media file (audio or video). - * - * Trying to get an instance with NULL [OCFile] or ownCloud [User] values will produce an - * [IllegalStateException]. - * - * By now, if the [OCFile] passed is not downloaded, an [IllegalStateException] is generated on - * instantiation too. - * - * Creates an empty fragment for previews. - * - * MUST BE KEPT: the system uses it when tries to reinstantiate a fragment automatically (for instance, when the - * device is turned a aside). - * - * DO NOT CALL IT: an [OCFile] and [User] must be provided for a successful construction - */ -@Suppress("NestedBlockDepth", "ComplexMethod", "LongMethod", "TooManyFunctions", "ReturnCount") -class PreviewMediaFragment : - FileFragment(), - OnTouchListener, - Injectable { - private var user: User? = null - private var savedPlaybackPosition: Long = 0 - - private var autoplay = true - private var isLivePhoto = false - private val prepared = false - - private var videoUri: Uri? = null - - @Inject - lateinit var clientFactory: ClientFactory - - @Inject - lateinit var accountManager: UserAccountManager - - @Inject - lateinit var backgroundJobManager: BackgroundJobManager - - lateinit var binding: FragmentPreviewMediaBinding - - private val exoplayerView: PlayerView - get() = binding.exoplayerView.root - - private var emptyListView: ViewGroup? = null - private var exoPlayer: ExoPlayer? = null - private var mediaSession: MediaSession? = null - private var nextcloudClient: NextcloudClient? = null - private var isFullscreenActive = false - - @OptIn(UnstableApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // release any background media session if exists - val intent = Intent(BackgroundPlayerService.RELEASE_MEDIA_SESSION_BROADCAST_ACTION).apply { - setPackage(requireActivity().packageName) - } - requireActivity().sendBroadcast(intent) - - arguments?.let { - initArguments(it) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - super.onCreateView(inflater, container, savedInstanceState) - Log_OC.v(TAG, "onCreateView") - - binding = FragmentPreviewMediaBinding.inflate(inflater, container, false) - emptyListView = binding.emptyView.emptyListView - setLoadingView() - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - Log_OC.v(TAG, "onActivityCreated") - - checkArgumentsAfterViewCreation(savedInstanceState) - - toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - addMenuHost() - } - - private fun checkArgumentsAfterViewCreation(savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - checkNotNull(file) { "Instanced with a NULL OCFile" } - checkNotNull(user) { "Instanced with a NULL ownCloud Account" } - } else { - file = savedInstanceState.getParcelableArgument(EXTRA_FILE, OCFile::class.java) - user = savedInstanceState.getParcelableArgument(EXTRA_USER, User::class.java) - savedPlaybackPosition = savedInstanceState.getInt(EXTRA_PLAY_POSITION).toLong() - autoplay = savedInstanceState.getBoolean(EXTRA_PLAYING) - } - } - - private fun initArguments(bundle: Bundle) { - file = bundle.getParcelableArgument(FILE, OCFile::class.java) - user = bundle.getParcelableArgument(USER, User::class.java) - - savedPlaybackPosition = bundle.getLong(PLAYBACK_POSITION) - autoplay = bundle.getBoolean(AUTOPLAY) - isLivePhoto = bundle.getBoolean(IS_LIVE_PHOTO) - } - - override fun onResume() { - super.onResume() - if (isFullscreenActive) { - return - } - applyWindowInsets() - prepareMedia() - } - - @OptIn(UnstableApi::class) - private fun applyWindowInsets() { - binding.root.post { - val rootInsets = ViewCompat.getRootWindowInsets(binding.root) ?: return@post - exoplayerView.applyControlsInsets( - rootInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - ) - ) - } - } - - private fun setLoadingView() { - binding.progress.visibility = View.VISIBLE - binding.emptyView.emptyListView.visibility = View.GONE - } - - private fun setVideoErrorMessage(headline: String, @StringRes message: Int = R.string.stream_not_possible_message) { - binding.emptyView.run { - emptyListViewHeadline.text = headline - emptyListViewText.setText(message) - emptyListIcon.setImageResource(R.drawable.file_movie) - emptyListViewText.visibility = View.VISIBLE - emptyListIcon.visibility = View.VISIBLE - emptyListView.visibility = View.VISIBLE - } - - binding.progress.visibility = View.GONE - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - - outState.run { - putParcelable(EXTRA_FILE, file) - putParcelable(EXTRA_USER, user) - - savedPlaybackPosition = exoPlayer?.currentPosition ?: savedPlaybackPosition - autoplay = exoPlayer?.isPlaying ?: autoplay - putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition) - putBoolean(EXTRA_PLAYING, autoplay) - } - } - - private fun prepareMedia() { - if (file == null || !isAdded) { - Log_OC.d(TAG, "File is null or fragment not attached to a context.") - return - } - prepareForVideo() - } - - @Suppress("DEPRECATION", "TooGenericExceptionCaught") - private fun prepareForVideo() { - if (exoPlayer != null) { - playVideo() - return - } - - lifecycleScope.launch { - try { - val client = withContext(Dispatchers.IO) { - clientFactory.createNextcloudClient(accountManager.user) - } - nextcloudClient = client - val ctx = this@PreviewMediaFragment.context ?: return@launch - - withContext(Dispatchers.Main) { - createExoPlayer(ctx, client) - playVideo() - } - } catch (e: CreationException) { - Log_OC.e(TAG, "error setting up ExoPlayer", e) - } - } - } - - private fun createExoPlayer(context: Context, client: NextcloudClient) { - exoPlayer = createNextcloudExoplayer(context, client) - exoPlayer?.let { - val listener = ExoplayerListener(context, exoplayerView, it) { goBackToLivePhoto() } - it.addListener(listener) - } - mediaSession = MediaSession.Builder( - context, - exoPlayer as Player - ).setId(System.currentTimeMillis().toString()).build() - } - - private fun releaseVideoPlayer() { - exoPlayer?.let { - savedPlaybackPosition = it.currentPosition - autoplay = it.playWhenReady - it.release() - mediaSession?.release() - } - mediaSession = null - exoPlayer = null - } - - private fun goBackToLivePhoto() { - if (!isLivePhoto) { - return - } - - showActionBar() - requireActivity().supportFragmentManager.popBackStack() - } - - private fun showActionBar() { - val currentActivity: Activity = requireActivity() - if (currentActivity is PreviewImageActivity) { - currentActivity.toggleActionBarVisibility(false) - } - } - - @OptIn(UnstableApi::class) - private fun setupVideoView() { - exoplayerView.run { - setShowNextButton(false) - setShowPreviousButton(false) - player = exoPlayer - setFullscreenButton(isFullscreen = false) { startFullScreenVideo() } - } - } - - private fun addMenuHost() { - val menuHost: MenuHost = requireActivity() - - menuHost.addMenuProvider( - object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menu.removeItem(R.id.action_search) - menuInflater.inflate(R.menu.custom_menu_placeholder, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.custom_menu_placeholder_item -> { - if (containerActivity.storageManager == null || file == null) return false - - val updatedFile = containerActivity.storageManager.getFileById(file.fileId) - file = updatedFile - file?.let { newFile -> - showFileActions(newFile) - } - - true - } - - else -> false - } - } - }, - viewLifecycleOwner, - Lifecycle.State.RESUMED - ) - } - - private fun showFileActions(file: OCFile) { - val additionalFilter = FileAction.getFilePreviewActions(getFile()) - newInstance(file, false, additionalFilter) - .setResultListener(childFragmentManager, this) { itemId: Int -> this.onFileActionChosen(itemId) } - .show(childFragmentManager, "actions") - } - - private fun onFileActionChosen(itemId: Int) { - when (itemId) { - R.id.action_send_share_file -> { - sendShareFile() - } - - R.id.action_open_file_with -> { - openFile() - } - - R.id.action_remove_file -> { - val dialog = RemoveFilesDialogFragment.newInstance(file) - dialog.show(requireFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION) - } - - R.id.action_see_details -> { - seeDetails() - } - - R.id.action_sync_file -> { - getTypedActivity(FileActivity::class.java)?.showSyncLoadingDialog(file.isFolder) - containerActivity.fileOperationsHelper.syncFileOrFolder(file) - } - - R.id.action_cancel_sync -> { - containerActivity.fileOperationsHelper.cancelTransference(file) - } - - R.id.action_stream_media -> { - containerActivity.fileOperationsHelper.streamMediaFile(file) - } - - R.id.action_export_file -> { - val list = ArrayList() - list.add(file) - containerActivity.fileOperationsHelper.exportFiles( - list, - context, - view, - backgroundJobManager - ) - } - - R.id.action_download_file -> { - instance().downloadFileIfNotStartedBefore(user!!, file) - } - } - } - - /** - * Update the file of the fragment with file value - * - * @param file Replaces the held file with a new one - */ - fun updateFile(file: OCFile?) { - setFile(file) - } - - private fun seeDetails() { - releaseVideoPlayer() - containerActivity.showDetails(file) - } - - private fun sendShareFile() { - releaseVideoPlayer() - containerActivity.fileOperationsHelper.sendShareFile(file) - } - - @Suppress("TooGenericExceptionCaught") - private fun playVideo() { - setupVideoView() - if (file.isDown) { - playVideoUri(file.storageUri) - return - } - - lifecycleScope.launch { - try { - val uri = withContext(Dispatchers.IO) { - loadStreamUrl(user, clientFactory, file.localId) - } - if (uri != null) { - videoUri = uri - playVideoUri(uri) - } else { - emptyListView?.visibility = View.VISIBLE - setVideoErrorMessage(getString(R.string.stream_not_possible_headline)) - } - } catch (e: Exception) { - Log_OC.e(TAG, "Loading stream url not possible: $e") - } - } - } - - private fun loadStreamUrl(user: User?, clientFactory: ClientFactory?, fileId: Long): Uri? { - val client: OwnCloudClient? = try { - clientFactory?.create(user) - } catch (e: CreationException) { - Log_OC.e(TAG, "Loading stream url not possible: $e") - return null - } - - val sfo = StreamMediaFileOperation(fileId) - val result = sfo.execute(client) - - if (result?.isSuccess == false) { - return null - } - - return (result?.data?.get(0) as String).toUri() - } - - private fun playVideoUri(uri: Uri) { - binding.progress.visibility = View.GONE - - exoPlayer?.setMediaItem(MediaItem.fromUri(uri)) - exoPlayer?.playWhenReady = autoplay - exoPlayer?.prepare() - - if (savedPlaybackPosition >= 0) { - exoPlayer?.seekTo(savedPlaybackPosition) - } - - // only autoplay video once - autoplay = false - } - - override fun onPause() { - if (!isFullscreenActive) { - releaseVideoPlayer() - } - super.onPause() - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(v: View, event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_DOWN && v == exoplayerView) { - // added a margin on the left to avoid interfering with gesture to open navigation drawer - if (event.x / Resources.getSystem().displayMetrics.density > MIN_DENSITY_RATIO) { - startFullScreenVideo() - } - return true - } - return false - } - - private fun startFullScreenVideo() { - val activity = activity ?: return - val client = nextcloudClient ?: return - val player = exoPlayer ?: return - - isFullscreenActive = true - val dialog = PreviewVideoFullscreenDialog( - activity, - client, - player, - exoplayerView - ).apply { - setOnDismissListener { - isFullscreenActive = false - setupVideoView() - } - } - - dialog.show() - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - Log_OC.v(TAG, "onConfigurationChanged $this") - } - - @Suppress("DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - Log_OC.v(TAG, "onActivityResult $this") - super.onActivityResult(requestCode, resultCode, data) - if (resultCode == Activity.RESULT_OK) { - savedPlaybackPosition = data?.getLongExtra(EXTRA_START_POSITION, 0) ?: 0L - autoplay = data?.getBooleanExtra(EXTRA_AUTOPLAY, false) ?: false - } - } - - /** - * Opens the previewed file with an external application. - */ - private fun openFile() { - containerActivity.fileOperationsHelper.openFile(file) - } - - val position: Long - get() { - if (prepared) { - savedPlaybackPosition = exoPlayer?.currentPosition ?: 0 - } - Log_OC.v(TAG, "getting position: $savedPlaybackPosition") - return savedPlaybackPosition - } - - private fun toggleDrawerLockMode(containerActivity: ContainerActivity, lockMode: Int) { - (containerActivity as DrawerActivity).setDrawerLockMode(lockMode) - } - - override fun onDetach() { - exoPlayer?.let { - it.stop() - it.release() - } - - super.onDetach() - } - - companion object { - private val TAG: String = PreviewMediaFragment::class.java.simpleName - - const val EXTRA_FILE: String = "FILE" - const val EXTRA_USER: String = "USER" - const val EXTRA_AUTOPLAY: String = "AUTOPLAY" - const val EXTRA_START_POSITION: String = "START_POSITION" - - private const val EXTRA_PLAY_POSITION = "PLAY_POSITION" - private const val EXTRA_PLAYING = "PLAYING" - private const val MIN_DENSITY_RATIO = 24.0 - - private const val FILE = "FILE" - private const val USER = "USER" - private const val PLAYBACK_POSITION = "PLAYBACK_POSITION" - private const val AUTOPLAY = "AUTOPLAY" - private const val IS_LIVE_PHOTO = "IS_LIVE_PHOTO" - - fun newInstance( - fileToDetail: OCFile?, - user: User?, - startPlaybackPosition: Long = 0, - autoplay: Boolean = false, - isLivePhoto: Boolean = false - ): PreviewMediaFragment = PreviewMediaFragment().apply { - arguments = Bundle().apply { - putParcelable(FILE, fileToDetail) - putParcelable(USER, user) - putLong(PLAYBACK_POSITION, startPlaybackPosition) - putBoolean(AUTOPLAY, autoplay) - putBoolean(IS_LIVE_PHOTO, isLivePhoto) - } - } - - fun isAudioOrVideo(file: OCFile?): Boolean = - file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt index 91237ec84f63..fcbfdc49de69 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaPagerAdapter.kt @@ -20,8 +20,10 @@ import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.fragment.SearchType import com.owncloud.android.utils.FileSortOrder import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeTypeUtil /** * Adapter class that provides Fragment instances @@ -29,7 +31,7 @@ import com.owncloud.android.utils.FileStorageUtils class PreviewMediaPagerAdapter : FragmentStateAdapter { private var selectedFile: OCFile? = null - private var imageFiles: MutableList = mutableListOf() + private var mediaFiles: MutableList = mutableListOf() private val user: User private val mObsoleteFragments: MutableSet private val mObsoletePositions: MutableSet @@ -37,6 +39,11 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { private val mStorageManager: FileDataStorageManager private val mCachedFragments: SparseArray + /** + * The collection the pages come from, so that a media page can build the same playback queue. + */ + private val searchType: SearchType? + /** * Constructor * @@ -60,12 +67,13 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this.user = user this.selectedFile = selectedFile mStorageManager = storageManager - imageFiles = mStorageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) + searchType = null + mediaFiles = mStorageManager.getFolderImagesAndVideos(parentFolder, onlyOnDevice) val sortOrder = preferences.getSortOrderByFolder(parentFolder) val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() val favoritesFirst = preferences.isSortFavoritesFirst() - imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) + mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) mObsoleteFragments = HashSet() mObsoletePositions = HashSet() @@ -93,19 +101,24 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { this.user = user mStorageManager = storageManager + searchType = when (type) { + VirtualFolderType.GALLERY -> SearchType.GALLERY_SEARCH + VirtualFolderType.FAVORITE -> SearchType.FAVORITE_SEARCH + else -> null + } if (type == VirtualFolderType.GALLERY) { - imageFiles = mStorageManager.allGalleryItems - imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) + mediaFiles = mStorageManager.allGalleryItems + mediaFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mediaFiles) } else { - imageFiles = mStorageManager.getVirtualFolderContent(type, true) + mediaFiles = mStorageManager.getVirtualFolderContent(type, true) } if (type == VirtualFolderType.FAVORITE) { val sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() val favoritesFirst = preferences.isSortFavoritesFirst() - imageFiles = sortOrder.sortCloudFiles(imageFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) + mediaFiles = sortOrder.sortCloudFiles(mediaFiles.toMutableList(), foldersBeforeFiles, favoritesFirst) } mObsoleteFragments = HashSet() @@ -115,7 +128,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } fun delete(position: Int) { - if (position < 0 || position >= imageFiles.size) { + if (position < 0 || position >= mediaFiles.size) { return } @@ -125,7 +138,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { mObsoletePositions.add(position) - imageFiles.removeAt(position) + mediaFiles.removeAt(position) mDownloadErrors.remove(position) mCachedFragments.remove(position) @@ -134,7 +147,7 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { @Suppress("TooGenericExceptionCaught") fun getFileAt(position: Int): OCFile? = try { - imageFiles[position] + mediaFiles[position] } catch (_: IndexOutOfBoundsException) { null } @@ -156,8 +169,8 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { } private fun fragmentForDownloaded(file: OCFile, ignoreFirstSavedState: Boolean): Fragment = - if (PreviewMediaFragment.isAudioOrVideo(file)) { - PreviewMediaFragment.newInstance(file, user) + if (file.isAudioOrVideo()) { + PreviewPlaybackFragment.newInstance(file, searchType) } else { PreviewImageFragment.newInstance(file, ignoreFirstSavedState, false) } @@ -173,34 +186,36 @@ class PreviewMediaPagerAdapter : FragmentStateAdapter { // without first being downloaded. file.isEncrypted -> FileDownloadFragment.newInstance(file, user, ignoreFirstSavedState) - PreviewMediaFragment.isAudioOrVideo(file) -> - PreviewMediaFragment.newInstance(file, user) + file.isAudioOrVideo() -> PreviewPlaybackFragment.newInstance(file, searchType) else -> PreviewImageFragment.newInstance(file, ignoreFirstSavedState, true) } } - fun getFilePosition(file: OCFile): Int = imageFiles.indexOf(file) + private fun OCFile.isAudioOrVideo(): Boolean = MimeTypeUtil.isAudio(this) || MimeTypeUtil.isVideo(this) + + fun getFilePosition(file: OCFile): Int = mediaFiles.indexOf(file) fun updateFile(position: Int, file: OCFile) { - val fragmentToUpdate = mCachedFragments[position] - if (fragmentToUpdate != null) { - mObsoleteFragments.add(fragmentToUpdate) + if (position < 0 || position >= mediaFiles.size) { + return } + + mCachedFragments[position]?.let { mObsoleteFragments.add(it) } mObsoletePositions.add(position) - imageFiles[position] = file + mediaFiles[position] = file } fun pendingErrorAt(position: Int): Boolean = mDownloadErrors.contains(position) override fun createFragment(position: Int): Fragment = getItem(position) - override fun getItemCount(): Int = imageFiles.size + override fun getItemCount(): Int = mediaFiles.size override fun getItemId(position: Int): Long { // The item ID function is needed to detect whether the deletion of the current item needs a UI update - return imageFiles.getOrNull(position)?.fileId ?: position.toLong() + return mediaFiles.getOrNull(position)?.fileId ?: position.toLong() } - override fun containsItem(itemId: Long): Boolean = imageFiles.any { it.fileId == itemId } + override fun containsItem(itemId: Long): Boolean = mediaFiles.any { it.fileId == itemId } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt new file mode 100644 index 000000000000..f89d1a5f07df --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewPlaybackFragment.kt @@ -0,0 +1,157 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.preview + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.player.media3.PlaybackModel +import com.nextcloud.client.player.model.ThumbnailLoader +import com.nextcloud.client.player.model.file.PlaybackFile +import com.nextcloud.client.player.model.file.toPlaybackFile +import com.nextcloud.client.player.model.state.PlaybackState +import com.nextcloud.client.player.model.state.VideoSize +import com.nextcloud.client.player.ui.PlayerLauncher +import com.nextcloud.client.player.util.applyVideoSize +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.extensions.getSerializableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.PreviewPlaybackFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.SearchType +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Plays an audio or video page of the preview pager in place, so that swiping between images and media keeps the + * user on the same screen. Playback itself is owned by the shared [PlaybackModel], the same one that drives + * [com.nextcloud.client.player.ui.PlayerActivity], notification and background playback. + */ +class PreviewPlaybackFragment : + Fragment(), + PlaybackModel.Listener { + + companion object { + private const val ARGUMENT_FILE = "ARGUMENT_FILE" + private const val ARGUMENT_SEARCH_TYPE = "ARGUMENT_SEARCH_TYPE" + private const val ARGUMENT_AUTOPLAY = "ARGUMENT_AUTOPLAY" + + fun newInstance(file: OCFile, searchType: SearchType?, autoplay: Boolean = false) = + PreviewPlaybackFragment().apply { + arguments = bundleOf( + ARGUMENT_FILE to file, + ARGUMENT_SEARCH_TYPE to searchType, + ARGUMENT_AUTOPLAY to autoplay + ) + } + } + + @Inject + lateinit var playbackModel: PlaybackModel + + @Inject + lateinit var playerLauncher: PlayerLauncher + + @Inject + lateinit var thumbnailLoader: ThumbnailLoader + + private lateinit var binding: PreviewPlaybackFragmentBinding + private lateinit var file: OCFile + private lateinit var playbackFile: PlaybackFile + private var searchType: SearchType? = null + private var autoplay: Boolean = false + private var previousVideoSize: VideoSize? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AndroidSupportInjection.inject(this) + file = arguments.getParcelableArgument(ARGUMENT_FILE, OCFile::class.java) + ?: throw IllegalArgumentException("bundle is not containing a file") + playbackFile = file.toPlaybackFile() + searchType = arguments.getSerializableArgument(ARGUMENT_SEARCH_TYPE, SearchType::class.java) + autoplay = arguments?.getBoolean(ARGUMENT_AUTOPLAY) == true + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = PreviewPlaybackFragmentBinding.inflate(inflater, container, false) + loadThumbnail() + return binding.root + } + + override fun onResume() { + super.onResume() + preparePlayback() + playbackModel.addListener(this) + binding.playerControlView.onStart() + render(playbackModel.state) + } + + override fun onPause() { + binding.playerControlView.onStop() + playbackModel.removeListener(this) + if (isCurrentItem(playbackModel.state)) { + playbackModel.pause() + playbackModel.setVideoSurfaceView(null) + } + super.onPause() + } + + override fun onPlaybackUpdate(state: PlaybackState) { + render(state) + } + + /** + * Reuses the queue the player already holds when it contains this page, so that swiping between media pages + * does not rebuild it. + */ + private fun preparePlayback() { + val state = playbackModel.state + if (state != null && state.currentFiles.any { it.id == playbackFile.id }) { + playbackModel.switchToFile(playbackFile) + if (autoplay) { + playbackModel.play() + } + } else { + playerLauncher.prepare(this, file, searchType, autoplay) + } + } + + private fun isCurrentItem(state: PlaybackState?): Boolean = state?.currentItemState?.file?.id == playbackFile.id + + private fun loadThumbnail() { + viewLifecycleOwner.lifecycleScope.launch { + val context = context ?: return@launch + val size = context.resources.getDimension(R.dimen.player_album_cover_size).toInt() + thumbnailLoader.await(context, playbackFile, size, size)?.let(binding.thumbnail::setImageBitmap) + } + } + + private fun render(state: PlaybackState?) { + if (!isCurrentItem(state)) { + binding.surfaceView.visibility = View.GONE + return + } + showVideo(state?.currentItemState?.videoSize) + } + + private fun showVideo(videoSize: VideoSize?) { + playbackModel.setVideoSurfaceView(binding.surfaceView) + binding.surfaceView.visibility = View.VISIBLE + binding.surfaceView.alpha = if (videoSize != null) 1f else 0f + + if (videoSize != null && previousVideoSize != videoSize) { + previousVideoSize = videoSize + binding.surfaceView.applyVideoSize(videoSize) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt deleted file mode 100644 index 205fd07ebeed..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewVideoFullscreenDialog.kt +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.ui.preview - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Build -import android.view.ViewGroup -import android.view.Window -import androidx.activity.addCallback -import androidx.annotation.OptIn -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.fragment.app.FragmentActivity -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.PlayerView -import com.nextcloud.client.media.ExoplayerListener -import com.nextcloud.client.media.NextcloudExoPlayer -import com.nextcloud.common.NextcloudClient -import com.nextcloud.utils.extensions.applyControlsInsets -import com.nextcloud.utils.extensions.setFullscreenButton -import com.owncloud.android.R -import com.owncloud.android.databinding.DialogPreviewVideoBinding -import com.owncloud.android.lib.common.utils.Log_OC - -/** - * Transfers a previously playing video to a fullscreen dialog, and handles the switch back to the previous player - * when closed - * - * @param activity the Activity hosting the original non-fullscreen player - * @param sourceExoPlayer the ExoPlayer playing the video - * @param sourceView the original non-fullscreen surface that [sourceExoPlayer] is linked to - */ -@OptIn(UnstableApi::class) -class PreviewVideoFullscreenDialog( - private val activity: FragmentActivity, - nextcloudClient: NextcloudClient, - private val sourceExoPlayer: ExoPlayer, - private val sourceView: PlayerView -) : Dialog(sourceView.context, R.style.Dialog_FullscreenVideo) { - - private val binding: DialogPreviewVideoBinding = DialogPreviewVideoBinding.inflate(layoutInflater) - - private val playerView: PlayerView - get() = binding.videoPlayer.root - - private var playingStateListener: androidx.media3.common.Player.Listener? = null - private var externalDismissListener: DialogInterface.OnDismissListener? = null - private var wasPlayingBeforeDismiss = false - - /** - * exoPlayer instance used for this view, either the original one or a new one in specific cases. - * @see getShouldUseRotatedVideoWorkaround - */ - private val mExoPlayer: ExoPlayer - - /** - * Videos with rotation metadata present a bug in sdk < 30 where they are rotated incorrectly and stretched when - * the video is resumed on a new surface. To work around this, in those circumstances we'll create a new ExoPlayer - * instance, which is slower but should avoid the bug. - */ - private val shouldUseRotatedVideoWorkaround - get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.R && isRotatedVideo() - - init { - addContentView( - binding.root, - ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - ) - mExoPlayer = getExoPlayer(nextcloudClient) - if (shouldUseRotatedVideoWorkaround) { - sourceExoPlayer.currentMediaItem?.let { mExoPlayer.setMediaItem(it, sourceExoPlayer.currentPosition) } - playerView.player = mExoPlayer - mExoPlayer.prepare() - } - super.setOnDismissListener { - restoreSourcePlayer() - externalDismissListener?.onDismiss(this) - } - handleOnBackPressed() - } - - /** - * Keeps the caller's listener instead of letting it replace the internal one, which has to run first to hand the - * playback back to [sourceView]. - */ - override fun setOnDismissListener(listener: DialogInterface.OnDismissListener?) { - externalDismissListener = listener - } - - private fun isRotatedVideo(): Boolean { - val videoFormat = sourceExoPlayer.videoFormat - return videoFormat != null && videoFormat.rotationDegrees != 0 - } - - private fun getExoPlayer(nextcloudClient: NextcloudClient): ExoPlayer = if (shouldUseRotatedVideoWorkaround) { - Log_OC.d(TAG, "Using new ExoPlayer instance to deal with rotated video") - NextcloudExoPlayer - .createNextcloudExoplayer(sourceView.context, nextcloudClient) - .apply { - addListener(ExoplayerListener(sourceView.context, playerView, this)) - } - } else { - sourceExoPlayer - } - - override fun show() { - val isPlaying = sourceExoPlayer.isPlaying - if (isPlaying) { - sourceExoPlayer.pause() - } - setOnShowListener { - enableImmersiveMode() - keepControlsClearOfSystemBars() - switchTargetViewFromSource() - playerView.setFullscreenButton(isFullscreen = true) { - activity.onBackPressedDispatcher.onBackPressed() - } - if (isPlaying) { - mExoPlayer.play() - } - } - super.show() - } - - private fun switchTargetViewFromSource() { - if (shouldUseRotatedVideoWorkaround) { - mExoPlayer.seekTo(sourceExoPlayer.currentPosition) - } else { - PlayerView.switchTargetView(sourceExoPlayer, sourceView, playerView) - } - } - - private fun handleOnBackPressed() { - activity.onBackPressedDispatcher.addCallback(activity) { - wasPlayingBeforeDismiss = mExoPlayer.isPlaying - if (wasPlayingBeforeDismiss) { - mExoPlayer.pause() - } - dismiss() - isEnabled = false - } - } - - private fun restoreSourcePlayer() { - playingStateListener?.let { - mExoPlayer.removeListener(it) - } - switchTargetViewToSource() - if (wasPlayingBeforeDismiss) { - sourceExoPlayer.play() - } - sourceView.showController() - } - - private fun switchTargetViewToSource() { - if (shouldUseRotatedVideoWorkaround) { - sourceExoPlayer.seekTo(mExoPlayer.currentPosition) - } else { - PlayerView.switchTargetView(sourceExoPlayer, playerView, sourceView) - } - } - - private fun enableImmersiveMode() { - val dialogWindow = window ?: return - WindowCompat.setDecorFitsSystemWindows(dialogWindow, false) - hideInset(dialogWindow, WindowInsetsCompat.Type.systemBars()) - } - - private fun hideInset(window: Window, type: Int) { - val windowInsetsController = - WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - windowInsetsController.hide(type) - } - - private fun keepControlsClearOfSystemBars() { - ViewCompat.setOnApplyWindowInsetsListener(playerView) { _, windowInsets -> - playerView.applyControlsInsets( - windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - ) - ) - windowInsets - } - ViewCompat.requestApplyInsets(playerView) - } - - companion object { - private val TAG = PreviewVideoFullscreenDialog::class.simpleName - } -} diff --git a/app/src/main/res/drawable-v33/player_ic_notification_audio.xml b/app/src/main/res/drawable-v33/player_ic_notification_audio.xml new file mode 100644 index 000000000000..49bf8fc8e7d5 --- /dev/null +++ b/app/src/main/res/drawable-v33/player_ic_notification_audio.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-v33/player_ic_notification_video.xml b/app/src/main/res/drawable-v33/player_ic_notification_video.xml new file mode 100644 index 000000000000..747fe4f3e90f --- /dev/null +++ b/app/src/main/res/drawable-v33/player_ic_notification_video.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_fast_forward.xml deleted file mode 100644 index a0c35cbe1ca0..000000000000 --- a/app/src/main/res/drawable/ic_fast_forward.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_fast_rewind.xml b/app/src/main/res/drawable/ic_fast_rewind.xml deleted file mode 100644 index 384f99cac3b2..000000000000 --- a/app/src/main/res/drawable/ic_fast_rewind.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml deleted file mode 100644 index 082d5c63db2d..000000000000 --- a/app/src/main/res/drawable/ic_pause.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml deleted file mode 100644 index a4705f2a34a2..000000000000 --- a/app/src/main/res/drawable/ic_skip_next.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml deleted file mode 100644 index 2809243d7b08..000000000000 --- a/app/src/main/res/drawable/ic_skip_previous.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/player_ic_audio.xml b/app/src/main/res/drawable/player_ic_audio.xml new file mode 100644 index 000000000000..f38528439cbc --- /dev/null +++ b/app/src/main/res/drawable/player_ic_audio.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_close.xml b/app/src/main/res/drawable/player_ic_close.xml new file mode 100644 index 000000000000..58190e40fcf8 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_close.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_notification_audio.xml b/app/src/main/res/drawable/player_ic_notification_audio.xml new file mode 100644 index 000000000000..75ee62145e8f --- /dev/null +++ b/app/src/main/res/drawable/player_ic_notification_audio.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_ic_notification_video.xml b/app/src/main/res/drawable/player_ic_notification_video.xml new file mode 100644 index 000000000000..def8fbc18cb7 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_notification_video.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_ic_pause.xml b/app/src/main/res/drawable/player_ic_pause.xml new file mode 100644 index 000000000000..c756bb9e0f11 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_pause.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/player_ic_play.xml b/app/src/main/res/drawable/player_ic_play.xml new file mode 100644 index 000000000000..694647bb8222 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_play.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/player_ic_repeat.xml b/app/src/main/res/drawable/player_ic_repeat.xml new file mode 100644 index 000000000000..5a8b3c1eca35 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_repeat.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_shuffle.xml b/app/src/main/res/drawable/player_ic_shuffle.xml new file mode 100644 index 000000000000..bdbd7182d917 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_shuffle.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_skip_next.xml b/app/src/main/res/drawable/player_ic_skip_next.xml new file mode 100644 index 000000000000..bad7839dfb7a --- /dev/null +++ b/app/src/main/res/drawable/player_ic_skip_next.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_skip_previous.xml b/app/src/main/res/drawable/player_ic_skip_previous.xml new file mode 100644 index 000000000000..088cf2a2b64d --- /dev/null +++ b/app/src/main/res/drawable/player_ic_skip_previous.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_ic_video.xml b/app/src/main/res/drawable/player_ic_video.xml new file mode 100644 index 000000000000..04aa20a161d0 --- /dev/null +++ b/app/src/main/res/drawable/player_ic_video.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/player_progress_drawable.xml b/app/src/main/res/drawable/player_progress_drawable.xml new file mode 100644 index 000000000000..9d2a7fe5a8b9 --- /dev/null +++ b/app/src/main/res/drawable/player_progress_drawable.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_progress_thumb.xml b/app/src/main/res/drawable/player_progress_thumb.xml new file mode 100644 index 000000000000..e7a6b68f805b --- /dev/null +++ b/app/src/main/res/drawable/player_progress_thumb.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_preview_media.xml b/app/src/main/res/layout/activity_preview_media.xml deleted file mode 100644 index e677a7776d98..000000000000 --- a/app/src/main/res/layout/activity_preview_media.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_preview_video.xml b/app/src/main/res/layout/dialog_preview_video.xml deleted file mode 100644 index c83f884cab8b..000000000000 --- a/app/src/main/res/layout/dialog_preview_video.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/fragment_preview_media.xml b/app/src/main/res/layout/fragment_preview_media.xml deleted file mode 100644 index 1ef72e192495..000000000000 --- a/app/src/main/res/layout/fragment_preview_media.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/grid_item.xml b/app/src/main/res/layout/grid_item.xml index 97e1084bdbd6..fe05edce1f7e 100644 --- a/app/src/main/res/layout/grid_item.xml +++ b/app/src/main/res/layout/grid_item.xml @@ -235,5 +235,15 @@ tools:ignore="TouchTargetSizeCheck" tools:visibility="visible" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/list_item.xml b/app/src/main/res/layout/list_item.xml index 96ce1a989ca7..46847db9f216 100644 --- a/app/src/main/res/layout/list_item.xml +++ b/app/src/main/res/layout/list_item.xml @@ -238,6 +238,14 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/player_audio_file_fragment.xml b/app/src/main/res/layout/player_audio_file_fragment.xml new file mode 100644 index 000000000000..d4ca5a968578 --- /dev/null +++ b/app/src/main/res/layout/player_audio_file_fragment.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_audio_view.xml b/app/src/main/res/layout/player_audio_view.xml new file mode 100644 index 000000000000..712a9f9b31f2 --- /dev/null +++ b/app/src/main/res/layout/player_audio_view.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_control_view.xml b/app/src/main/res/layout/player_control_view.xml new file mode 100644 index 000000000000..70afd3c22256 --- /dev/null +++ b/app/src/main/res/layout/player_control_view.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_pager.xml b/app/src/main/res/layout/player_pager.xml new file mode 100644 index 000000000000..2b953b9b2dc6 --- /dev/null +++ b/app/src/main/res/layout/player_pager.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/player_video_file_fragment.xml b/app/src/main/res/layout/player_video_file_fragment.xml new file mode 100644 index 000000000000..d31b9e3c05f4 --- /dev/null +++ b/app/src/main/res/layout/player_video_file_fragment.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_video_view.xml b/app/src/main/res/layout/player_video_view.xml new file mode 100644 index 000000000000..51dad2a93777 --- /dev/null +++ b/app/src/main/res/layout/player_video_view.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/preview_playback_fragment.xml b/app/src/main/res/layout/preview_playback_fragment.xml new file mode 100644 index 000000000000..8f791103cc85 --- /dev/null +++ b/app/src/main/res/layout/preview_playback_fragment.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_media_player.xml b/app/src/main/res/layout/view_media_player.xml deleted file mode 100644 index d42e7e3df036..000000000000 --- a/app/src/main/res/layout/view_media_player.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/main/res/values-land/dims.xml b/app/src/main/res/values-land/dims.xml new file mode 100644 index 000000000000..e09cf5424063 --- /dev/null +++ b/app/src/main/res/values-land/dims.xml @@ -0,0 +1,16 @@ + + + + + 56dp + 8dp + 0dp + 8dp + 0dp + 8dp + \ No newline at end of file diff --git a/app/src/main/res/values-large-land/dims.xml b/app/src/main/res/values-large-land/dims.xml new file mode 100644 index 000000000000..07ce8f787cad --- /dev/null +++ b/app/src/main/res/values-large-land/dims.xml @@ -0,0 +1,16 @@ + + + + + 56dp + 24dp + 8dp + 24dp + 16dp + 16dp + \ No newline at end of file diff --git a/app/src/main/res/values-large/dims.xml b/app/src/main/res/values-large/dims.xml new file mode 100644 index 000000000000..3a56e875553b --- /dev/null +++ b/app/src/main/res/values-large/dims.xml @@ -0,0 +1,18 @@ + + + + + 64dp + 12dp + 24dp + 24dp + 48dp + 48dp + 40dp + 72dp + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d8edd0a0e602..856fc7efd30e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -88,4 +88,15 @@ #A5A5A5 #EFEFEF + + + @color/color_accent + #111111 + @color/white + @color/white + @color/white + @color/white + #21000000 + #21000000 + #979797 diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index ffc32ec8b7a8..39c0115ef799 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -155,4 +155,28 @@ 18dp 18dp 24dp + + + 56dp + 21sp + 4dp + 12dp + 16dp + 16dp + 16sp + 8dp + 12sp + 32dp + 350dp + 96dp + 20dp + 8dp + 32dp + 2dp + 4dp + 10dp + 15sp + 16dp + 8dp + 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b6cb7d99a11..d63bc79964d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -401,17 +401,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. @@ -481,11 +470,6 @@ - The URL does not match the hostname in the certificate Do you want to trust this certificate anyway? Could not save certificate - Seek forward - Seek backward - Play - Pause - Playing media Details Hide @@ -1056,7 +1040,6 @@ Delete permanently Stream with… Internal streaming not possible - Please download media instead or use external app. Folder already exists Notification icon Create @@ -1269,8 +1252,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. @@ -1549,4 +1530,17 @@ You do not have permission to change this label Governance Image details + + + Export started (%d file) + Exports started (%d files) + + Source not found + Close + Modified: %s + 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 c3f303d1839a..448112367752 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -231,11 +231,6 @@ false - - @@ -515,4 +510,43 @@ + + + + + + diff --git a/app/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt b/app/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt deleted file mode 100644 index f40e312d9255..000000000000 --- a/app/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioFocusRequest -import android.media.AudioManager -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -class AudioFocusManagerTest { - - private val audioManager: AudioManager = mock() - private val callback: (AudioFocus) -> Unit = mock() - private lateinit var audioFocusManager: AudioFocusManager - - private val builder: AudioFocusRequest.Builder = mock() - private val focusRequest: AudioFocusRequest = mock() - - @Before - fun setUp() { - // Chain mock methods for the builder - whenever(builder.setWillPauseWhenDucked(true)).thenReturn(builder) - whenever(builder.setOnAudioFocusChangeListener(any())).thenReturn(builder) - whenever(builder.build()).thenReturn(focusRequest) - - audioFocusManager = AudioFocusManager(audioManager, callback, builder) - - whenever(audioManager.requestAudioFocus(focusRequest)) - .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) - - whenever(audioManager.abandonAudioFocusRequest(focusRequest)) - .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) - } - - @Test - fun `requestFocus should invoke FOCUS callback when granted`() { - audioFocusManager.requestFocus() - verify(callback).invoke(AudioFocus.FOCUS) - } - - @Test - fun `requestFocus should invoke LOST callback when denied`() { - whenever(audioManager.requestAudioFocus(focusRequest)) - .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_FAILED) - - audioFocusManager.requestFocus() - verify(callback).invoke(AudioFocus.LOST) - } - - @Test - fun `releaseFocus should invoke LOST callback`() { - audioFocusManager.releaseFocus() - verify(callback).invoke(AudioFocus.LOST) - } -} diff --git a/app/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt b/app/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt deleted file mode 100644 index 259cb9119af4..000000000000 --- a/app/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import android.media.AudioManager -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Test - -class AudioFocusTest { - private companion object { - const val INVALID_FOCUS = -10000 - } - - @Test - fun `invalid values result in null`() { - val focus = AudioFocus.fromPlatformFocus(INVALID_FOCUS) - assertNull(focus) - } - - @Test - fun `audio focus values are converted`() { - val validValues = listOf( - AudioManager.AUDIOFOCUS_GAIN, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, - AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - ) - validValues.forEach { - val focus = AudioFocus.fromPlatformFocus(-it) - assertNotNull(focus) - } - } -} diff --git a/app/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt b/app/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt deleted file mode 100644 index 5d7ac669d761..000000000000 --- a/app/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt +++ /dev/null @@ -1,670 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.media - -import com.nextcloud.client.media.PlayerStateMachine.Event -import com.nextcloud.client.media.PlayerStateMachine.State -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Suite -import org.mockito.Mock -import org.mockito.MockitoAnnotations -import org.mockito.kotlin.eq -import org.mockito.kotlin.inOrder -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -@RunWith(Suite::class) -@Suite.SuiteClasses( - PlayerStateMachineTest.Constructor::class, - PlayerStateMachineTest.EventHandling::class, - PlayerStateMachineTest.Stopped::class, - PlayerStateMachineTest.Downloading::class, - PlayerStateMachineTest.Preparing::class, - PlayerStateMachineTest.AwaitFocus::class, - PlayerStateMachineTest.Focused::class, - PlayerStateMachineTest.Ducked::class, - PlayerStateMachineTest.Paused::class -) -internal class PlayerStateMachineTest { - - abstract class Base { - @Mock - protected lateinit var delegate: PlayerStateMachine.Delegate - protected lateinit var fsm: PlayerStateMachine - - fun setUp(initialState: State) { - MockitoAnnotations.initMocks(this) - fsm = PlayerStateMachine(initialState, delegate) - } - } - - class Constructor { - - private val delegate: PlayerStateMachine.Delegate = mock() - - @Test - fun `default state is stopped`() { - val fsm = PlayerStateMachine(delegate) - assertEquals(State.STOPPED, fsm.state) - } - - @Test - fun `inital state can be set`() { - val fsm = PlayerStateMachine(State.PREPARING, delegate) - assertEquals(State.PREPARING, fsm.state) - } - } - - class EventHandling : Base() { - - @Before - fun setUp() { - super.setUp(State.STOPPED) - } - - @Test - fun `can post multiple events from callback`() { - whenever(delegate.isDownloaded).thenReturn(false) - whenever(delegate.isAutoplayEnabled).thenReturn(false) - whenever(delegate.hasEnqueuedFile).thenReturn(true) - whenever(delegate.onStartDownloading()).thenAnswer { - fsm.post(Event.DOWNLOADED) - fsm.post(Event.PREPARED) - } - - // WHEN - // an event is posted from a state machine callback - fsm.post(Event.PLAY) // posts error() in callback - - // THEN - // enqueued events is handled triggering transitions - assertEquals(State.PAUSED, fsm.state) - verify(delegate).onStartRunning() - verify(delegate).onStartDownloading() - verify(delegate).onPrepare() - verify(delegate).onPausePlayback() - } - - @Test - fun `unhandled events are ignored`() { - // GIVEN - // state machine is in STOPPED state - // PAUSE event is not handled in this staet - - // WHEN - // state machine receives unhandled PAUSE event - fsm.post(Event.PAUSE) - - // THEN - // event is ignored - // exception is not thrown - } - } - - class Stopped : Base() { - - @Before - fun setUp() { - super.setUp(State.STOPPED) - } - - @Test - fun `initiall state is stopped`() { - assertEquals(State.STOPPED, fsm.state) - } - - @Test - fun `playing requires enqueued file`() { - // GIVEN - // no file is enqueued - whenever(delegate.hasEnqueuedFile).thenReturn(false) - - // WHEN - // play is triggered - fsm.post(Event.PLAY) - - // THEN - // remains in stopped state - assertEquals(State.STOPPED, fsm.state) - } - - @Test - fun `playing remote media triggers downloading`() { - // GIVEN - // file is enqueued - // media is not downloaded - whenever(delegate.hasEnqueuedFile).thenReturn(true) - whenever(delegate.isDownloaded).thenReturn(false) - - // WHEN - // play is requested - fsm.post(Event.PLAY) - - // THEN - // enqueued file is loaded - // media stream download starts - assertEquals(State.DOWNLOADING, fsm.state) - verify(delegate).onStartRunning() - verify(delegate).onStartDownloading() - } - - @Test - fun `playing local media triggers player preparation`() { - // GIVEN - // file is enqueued - // media is downloaded - whenever(delegate.hasEnqueuedFile).thenReturn(true) - whenever(delegate.isDownloaded).thenReturn(true) - - // WHEN - // play is requested - fsm.post(Event.PLAY) - - // THEN - // player preparation starts - assertEquals(State.PREPARING, fsm.state) - verify(delegate).onPrepare() - } - } - - class Downloading : Base() { - - // GIVEN - // player is downloading stream URL - @Before - fun setUp() { - setUp(State.DOWNLOADING) - } - - @Test - fun `stream url download is successfull`() { - // WHEN - // stream url downloaded - fsm.post(Event.DOWNLOADED) - - // THEN - // player is preparing - assertEquals(State.PREPARING, fsm.state) - verify(delegate).onPrepare() - } - - @Test - fun `stream url download failed`() { - // WHEN - // download error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - - @Test - fun `player stopped`() { - // WHEN - // download error - fsm.post(Event.STOP) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onStopped() - } - - @Test - fun `player error`() { - // WHEN - // player error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // error handler is called - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - } - - class Preparing : Base() { - - @Before - fun setUp() { - setUp(State.PREPARING) - } - - @Test - fun `start in autoplay mode`() { - // GIVEN - // media player is preparing - // autoplay is enabled - whenever(delegate.isAutoplayEnabled).thenReturn(true) - - // WHEN - // media player is ready - fsm.post(Event.PREPARED) - - // THEN - // start playing - // request audio focus - // awaiting focus - assertEquals(State.AWAIT_FOCUS, fsm.state) - verify(delegate).onRequestFocus() - } - - @Test - fun `start in paused mode`() { - // GIVEN - // media player is preparing - // autoplay is disabled - whenever(delegate.isAutoplayEnabled).thenReturn(false) - - // WHEN - // media player is ready - fsm.post(Event.PREPARED) - - // THEN - // media player is not started - assertEquals(State.PAUSED, fsm.state) - verify(delegate, never()).onStartPlayback() - } - - @Test - fun `player is stopped during preparation`() { - // GIVEN - // media player is preparing - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onStopped() - } - - @Test - fun `error during preparation`() { - // GIVEN - // media player is preparing - // WHEN - // download error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // error callback is invoked - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - } - - class AwaitFocus : Base() { - - @Before - fun setUp() { - setUp(State.AWAIT_FOCUS) - } - - @Test - fun pause() { - // GIVEN - // media player is awaiting focus - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // media player enters paused state - // focus is released - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `audio focus denied`() { - // GIVEN - // media player is awaiting focus - // WHEN - // audio focus was denied - fsm.post(Event.FOCUS_LOST) - - // THEN - // media player enters paused state - assertEquals(State.PAUSED, fsm.state) - verify(delegate).onPausePlayback() - } - - @Test - fun `audio focus granted`() { - // GIVEN - // media player is awaiting focus - // WHEN - // audio focus was granted - fsm.post(Event.FOCUS_GAIN) - - // THEN - // media player enters focused state - // playback is started - assertEquals(State.FOCUSED, fsm.state) - verify(delegate).onStartPlayback() - } - - @Test - fun stop() { - // GIVEN - // media player is awaiting focus - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - // focus is released - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onStopped() - } - } - - @Test - fun error() { - // GIVEN - // media player is playing - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // focus is released - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onError() - } - } - } - - class Focused : Base() { - - @Before - fun setUp() { - setUp(State.FOCUSED) - } - - @Test - fun pause() { - // GIVEN - // media player is awaiting focus - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // media player enters paused state - // focus is released - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `lost focus`() { - // GIVEN - // media player is awaiting focus - // WHEN - // media player lost audio focus - fsm.post(Event.FOCUS_LOST) - - // THEN - // media player enters paused state - // focus is released - assertEquals(State.PAUSED, fsm.state) - verify(delegate).onPausePlayback() - } - - @Test - fun `audio focus duck`() { - // GIVEN - // media player is playing - // WHEN - // media player focus duck is requested - fsm.post(Event.FOCUS_DUCK) - - // THEN - // media player ducks - assertEquals(State.DUCKED, fsm.state) - verify(delegate).onAudioDuck(eq(true)) - } - - @Test - fun stop() { - // GIVEN - // media player is awaiting focus - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - // focus is released - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onStopped() - } - } - - @Test - fun error() { - // GIVEN - // media player is playing - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // focus is released - // error is signaled - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onReleaseFocus() - verify(delegate).onError() - } - } - } - - class Ducked : Base() { - - @Before - fun setUp() { - setUp(State.DUCKED) - } - - @Test - fun pause() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is paused - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `lost focus`() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player is looses focus - fsm.post(Event.FOCUS_LOST) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is paused - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - // WHEN - // media player is paused - fsm.post(Event.PAUSE) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is paused - assertEquals(State.PAUSED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onPausePlayback() - } - } - - @Test - fun `audio focus is re-gained`() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player focus duck is requested - fsm.post(Event.FOCUS_GAIN) - - // THEN - // media player is focused - // audio focus duck is disabled - // playback is not restarted - assertEquals(State.FOCUSED, fsm.state) - verify(delegate).onAudioDuck(eq(false)) - verify(delegate, never()).onStartPlayback() - } - - @Test - fun stop() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // media player is stopped - fsm.post(Event.STOP) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is stopped - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onStopped() - } - } - - @Test - fun error() { - // GIVEN - // media player is playing - // audio focus is ducked - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // audio focus duck is disabled - // focus is released - // playback is stopped - // error is signaled - assertEquals(State.STOPPED, fsm.state) - inOrder(delegate).run { - verify(delegate).onAudioDuck(eq(false)) - verify(delegate).onReleaseFocus() - verify(delegate).onError() - } - } - } - - class Paused : Base() { - - @Before - fun setUp() { - setUp(State.PAUSED) - } - - @Test - fun pause() { - // GIVEN - // media player is paused - // WHEN - // media player is resumed - fsm.post(Event.PLAY) - - // THEN - // media player enters playing state - // audio focus is requsted - assertEquals(State.AWAIT_FOCUS, fsm.state) - verify(delegate).onRequestFocus() - } - - @Test - fun stop() { - // GIVEN - // media player is playing - // WHEN - // stopped - fsm.post(Event.STOP) - - // THEN - // player is stopped - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onStopped() - } - - @Test - fun error() { - // GIVEN - // media player is playing - // WHEN - // error - fsm.post(Event.ERROR) - - // THEN - // player is stopped - // error callback is invoked - assertEquals(State.STOPPED, fsm.state) - verify(delegate).onError() - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a2b76c85ac1..1cc5b3ac3eb1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,6 +87,7 @@ workRuntime = "2.11.2" foundationVersion = "1.12.0" browserVersion = "1.10.0" kotlinxCoroutinesTestVersion = "1.11.0" +kotlinxCoroutinesVersion = "1.11.0" [libraries] # Crypto @@ -239,6 +240,9 @@ work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRunti work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" } foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesVersion" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesVersion" } + [bundles] media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource"] espresso = ["espresso-core", "espresso-contrib", "espresso-web", "espresso-accessibility", "espresso-intents", "espresso-idling-resource"]