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