Răsfoiți Sursa

Merge pull request #13467 from nextcloud/improve-media

Improve Media Player
Alper Öztürk 8 luni în urmă
părinte
comite
233d8a9567

+ 1 - 0
app/build.gradle

@@ -333,6 +333,7 @@ dependencies {
     implementation 'org.conscrypt:conscrypt-android:2.5.3'
 
     implementation "androidx.media3:media3-ui:$androidxMediaVersion"
+    implementation "androidx.media3:media3-session:$androidxMediaVersion"
     implementation "androidx.media3:media3-exoplayer:$androidxMediaVersion"
     implementation "androidx.media3:media3-datasource-okhttp:$androidxMediaVersion"
 

+ 10 - 0
app/src/main/AndroidManifest.xml

@@ -350,6 +350,16 @@
             android:exported="false"
             android:theme="@style/Theme.ownCloud.Media" />
 
+        <service
+            android:name="com.nextcloud.client.media.BackgroundPlayerService"
+            android:foregroundServiceType="mediaPlayback"
+            android:exported="true"
+            tools:ignore="ExportedService">
+            <intent-filter>
+                <action android:name="androidx.media3.session.MediaSessionService"/>
+            </intent-filter>
+        </service>
+
         <service
             android:name=".authentication.AccountAuthenticatorService"
             android:exported="false">

+ 7 - 1
app/src/main/java/com/nextcloud/client/di/AppComponent.java

@@ -17,6 +17,7 @@ import com.nextcloud.client.integrations.IntegrationsModule;
 import com.nextcloud.client.jobs.JobsModule;
 import com.nextcloud.client.jobs.download.FileDownloadHelper;
 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;
@@ -27,6 +28,8 @@ 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;
@@ -46,7 +49,7 @@ import dagger.android.support.AndroidSupportInjectionModule;
     ThemeModule.class,
     DatabaseModule.class,
     DispatcherModule.class,
-    VariantModule.class
+    VariantModule.class,
 })
 @Singleton
 public interface AppComponent {
@@ -55,6 +58,9 @@ public interface AppComponent {
 
     void inject(MediaControlView mediaControlView);
 
+    @OptIn(markerClass = UnstableApi.class)
+    void inject(BackgroundPlayerService backgroundPlayerService);
+
     void inject(ThemeableSwitchPreference switchPreference);
 
     void inject(FileUploadHelper fileUploadHelper);

+ 10 - 1
app/src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -17,6 +17,7 @@ import com.nextcloud.client.jobs.transfer.FileTransferService;
 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;
@@ -123,6 +124,8 @@ import com.owncloud.android.ui.preview.PreviewTextStringFragment;
 import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment;
 import com.owncloud.android.ui.trashbin.TrashbinActivity;
 
+import androidx.annotation.OptIn;
+import androidx.media3.common.util.UnstableApi;
 import dagger.Module;
 import dagger.android.ContributesAndroidInjector;
 
@@ -481,7 +484,13 @@ abstract class ComponentsModule {
 
     @ContributesAndroidInjector
     abstract TestJob testJob();
-    
+
     @ContributesAndroidInjector
     abstract InternalTwoWaySyncActivity internalTwoWaySyncActivity();
+
+
+    @OptIn(markerClass = UnstableApi.class)
+    @ContributesAndroidInjector
+    abstract BackgroundPlayerService backgroundPlayerService();
+
 }

+ 245 - 0
app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt

@@ -0,0 +1,245 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Parneet Singh <gurayaparneet@gmail.com>
+ * 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.os.Bundle
+import androidx.annotation.OptIn
+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.datamodel.ReceiverFlag
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@OptIn(UnstableApi::class)
+class BackgroundPlayerService : MediaSessionService(), Injectable {
+
+    private val seekBackSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_BACK, Bundle.EMPTY)
+    private val seekForwardSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_FORWARD, Bundle.EMPTY)
+
+    val seekForward =
+        CommandButton.Builder()
+            .setDisplayName("Seek Forward")
+            .setIconResId(CommandButton.getIconResIdForIconConstant(CommandButton.ICON_SKIP_FORWARD_15))
+            .setSessionCommand(seekForwardSessionCommand)
+            .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 2) })
+            .build()
+
+    val seekBackward =
+        CommandButton.Builder()
+            .setDisplayName("Seek Backward")
+            .setIconResId(CommandButton.getIconResIdForIconConstant(CommandButton.ICON_SKIP_BACK_5))
+            .setSessionCommand(seekBackSessionCommand)
+            .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 0) })
+            .build()
+
+    @Inject
+    lateinit var clientFactory: ClientFactory
+
+    @Inject
+    lateinit var userAccountManager: UserAccountManager
+    lateinit var exoPlayer: ExoPlayer
+    private var mediaSession: MediaSession? = null
+
+    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 -> exoPlayer.stop()
+            }
+        }
+    }
+
+    override fun onCreate() {
+        super.onCreate()
+
+        registerBroadcastReceiver(
+            stopReceiver,
+            IntentFilter().apply {
+                addAction(RELEASE_MEDIA_SESSION_BROADCAST_ACTION)
+                addAction(STOP_MEDIA_SESSION_BROADCAST_ACTION)
+            },
+            ReceiverFlag.NotExported
+        )
+
+        MainApp.getAppComponent().inject(this)
+        initNextcloudExoPlayer()
+
+        setMediaNotificationProvider(object : DefaultMediaNotificationProvider(this) {
+            override fun getMediaButtons(
+                session: MediaSession,
+                playerCommands: Player.Commands,
+                customLayout: ImmutableList<CommandButton>,
+                showPauseButton: Boolean
+            ): ImmutableList<CommandButton> {
+                val playPauseButton =
+                    CommandButton.Builder()
+                        .setDisplayName("PlayPause")
+                        .setIconResId(
+                            CommandButton.getIconResIdForIconConstant(
+                                if (mediaSession?.player?.isPlaying == true) {
+                                    CommandButton.ICON_PAUSE
+                                } else {
+                                    CommandButton.ICON_PLAY
+                                }
+                            )
+                        )
+                        .setPlayerCommand(COMMAND_PLAY_PAUSE)
+                        .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 1) })
+                        .build()
+
+                val myCustomButtonsLayout =
+                    ImmutableList.of(seekBackward, playPauseButton, seekForward)
+                return myCustomButtonsLayout
+            }
+        })
+    }
+
+    private fun initNextcloudExoPlayer() {
+        runBlocking {
+            var nextcloudClient: NextcloudClient
+            withContext(Dispatchers.IO) {
+                nextcloudClient = clientFactory.createNextcloudClient(userAccountManager.user)
+            }
+            nextcloudClient.let {
+                exoPlayer = createNextcloudExoplayer(this@BackgroundPlayerService, nextcloudClient)
+                mediaSession =
+                    MediaSession.Builder(applicationContext, exoPlayer)
+                        // set id to distinct this session to avoid crash
+                        // in case session release delayed a bit and
+                        // we start another session for eg. video
+                        .setId(BACKGROUND_MEDIA_SESSION_ID)
+                        .setCustomLayout(listOf(seekBackward, seekForward))
+                        .setCallback(object : MediaSession.Callback {
+                            override fun onConnect(
+                                session: MediaSession,
+                                controller: MediaSession.ControllerInfo
+                            ): ConnectionResult {
+                                return AcceptedResultBuilder(mediaSession!!)
+                                    .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<SessionResult> {
+                                return 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()
+            }
+        }
+    }
+
+    override fun onTaskRemoved(rootIntent: Intent?) {
+        release()
+    }
+
+    override fun onDestroy() {
+        unregisterReceiver(stopReceiver)
+        mediaSession?.run {
+            player.release()
+            release()
+            mediaSession = null
+        }
+        super.onDestroy()
+    }
+
+    private fun release() {
+        val player = mediaSession?.player
+        if (player?.playWhenReady == true) {
+            // Make sure the service is not in foreground.
+            player.pause()
+        }
+        // Bug in Android 14, https://github.com/androidx/media/issues/805
+        // that sometimes onTaskRemove() doesn't get called immediately
+        // eventually gets called so the service stops but the notification doesn't clear out.
+        // [WORKAROUND] So, explicitly removing the notification here.
+        // TODO revisit after bug solved!
+        val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+        nm.cancel(DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID)
+        stopSelf()
+    }
+
+    override fun onGetSession(p0: MediaSession.ControllerInfo): MediaSession? {
+        return mediaSession
+    }
+
+    companion object {
+        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"
+    }
+}

+ 36 - 38
app/src/main/java/com/owncloud/android/media/MediaControlView.kt

@@ -23,10 +23,10 @@ import android.view.View
 import android.view.accessibility.AccessibilityEvent
 import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.LinearLayout
-import android.widget.MediaController.MediaPlayerControl
 import android.widget.SeekBar
 import android.widget.SeekBar.OnSeekBarChangeListener
 import androidx.core.content.ContextCompat
+import androidx.media3.common.Player
 import com.owncloud.android.MainApp
 import com.owncloud.android.R
 import com.owncloud.android.databinding.MediaControlBinding
@@ -50,7 +50,7 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
     View.OnClickListener,
     OnSeekBarChangeListener {
 
-    private var playerControl: MediaPlayerControl? = null
+    private var playerControl: Player? = null
     private var binding: MediaControlBinding
     private var isDragging = false
 
@@ -62,7 +62,7 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
     }
 
     @Suppress("MagicNumber")
-    fun setMediaPlayer(player: MediaPlayerControl?) {
+    fun setMediaPlayer(player: Player?) {
         playerControl = player
         handler.sendEmptyMessage(SHOW_PROGRESS)
 
@@ -72,10 +72,6 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
         }, 100)
     }
 
-    fun stopMediaPlayerMessages() {
-        handler.removeMessages(SHOW_PROGRESS)
-    }
-
     @Suppress("MagicNumber")
     private fun initControllerView() {
         binding.playBtn.requestFocus()
@@ -104,14 +100,15 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
      */
     private fun disableUnsupportedButtons() {
         try {
-            if (playerControl?.canPause() == false) {
-                binding.playBtn.setEnabled(false)
+            if (playerControl?.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)?.not() == true) {
+                binding.playBtn.isEnabled = false
             }
-            if (playerControl?.canSeekBackward() == false) {
-                binding.rewindBtn.setEnabled(false)
+
+            if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK)?.not() == true) {
+                binding.rewindBtn.isEnabled = false
             }
-            if (playerControl?.canSeekForward() == false) {
-                binding.forwardBtn.setEnabled(false)
+            if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD)?.not() == true) {
+                binding.forwardBtn.isEnabled = false
             }
         } catch (ex: IncompatibleClassChangeError) {
             // We were given an old version of the interface, that doesn't have
@@ -130,7 +127,7 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
                 val pos = setProgress()
 
                 if (!isDragging) {
-                    sendMessageDelayed(obtainMessage(SHOW_PROGRESS), (1000 - pos % 1000).toLong())
+                    sendMessageDelayed(obtainMessage(SHOW_PROGRESS), (1000 - pos % 1000))
                 }
             }
         }
@@ -149,7 +146,7 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
     }
 
     @Suppress("MagicNumber")
-    private fun formatTime(timeMs: Int): String {
+    private fun formatTime(timeMs: Long): String {
         val totalSeconds = timeMs / 1000
         val seconds = totalSeconds % 60
         val minutes = totalSeconds / 60 % 60
@@ -164,8 +161,8 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
     }
 
     @Suppress("MagicNumber")
-    private fun setProgress(): Int {
-        var position = 0
+    private fun setProgress(): Long {
+        var position = 0L
         if (playerControl == null || isDragging) {
             position = 0
         }
@@ -178,7 +175,7 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
                 val pos = 1000L * position / duration
                 binding.progressBar.progress = pos.toInt()
             }
-            val percent = playerControl.bufferPercentage
+            val percent = playerControl.bufferedPercentage
             binding.progressBar.setSecondaryProgress(percent * 10)
             val endTime = if (duration > 0) formatTime(duration) else "--:--"
             binding.totalTimeText.text = endTime
@@ -202,22 +199,25 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
                 }
                 return true
             }
+
             KeyEvent.KEYCODE_MEDIA_PLAY -> {
-                if (uniqueDown && playerControl?.isPlaying == false) {
-                    playerControl?.start()
+                if (uniqueDown && playerControl?.playWhenReady == false) {
+                    playerControl?.play()
                     updatePausePlay()
                 }
                 return true
             }
+
             KeyEvent.KEYCODE_MEDIA_STOP,
             KeyEvent.KEYCODE_MEDIA_PAUSE
             -> {
-                if (uniqueDown && playerControl?.isPlaying == true) {
+                if (uniqueDown && playerControl?.playWhenReady == true) {
                     playerControl?.pause()
                     updatePausePlay()
                 }
                 return true
             }
+
             else -> return super.dispatchKeyEvent(event)
         }
     }
@@ -225,18 +225,21 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
     fun updatePausePlay() {
         binding.playBtn.icon = ContextCompat.getDrawable(
             context,
+            // use isPlaying instead of playWhenReady
+            // it represents only the play/pause state
+            // which is needed to show play/pause icons
             if (playerControl?.isPlaying == true) {
                 R.drawable.ic_pause
             } else {
                 R.drawable.ic_play
             }
         )
-        binding.forwardBtn.visibility = if (playerControl?.canSeekForward() == true) {
+        binding.forwardBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_FORWARD) == true) {
             VISIBLE
         } else {
             INVISIBLE
         }
-        binding.rewindBtn.visibility = if (playerControl?.canSeekBackward() == true) {
+        binding.rewindBtn.visibility = if (playerControl?.isCommandAvailable(Player.COMMAND_SEEK_BACK) == true) {
             VISIBLE
         } else {
             INVISIBLE
@@ -245,10 +248,10 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
 
     private fun doPauseResume() {
         playerControl?.run {
-            if (isPlaying) {
+            if (playWhenReady) {
                 pause()
             } else {
-                start()
+                play()
             }
         }
         updatePausePlay()
@@ -267,30 +270,25 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
 
     @Suppress("MagicNumber")
     override fun onClick(v: View) {
-        var pos: Int
-
         playerControl?.let { playerControl ->
-            val playing = playerControl.isPlaying
+            val playing = playerControl.playWhenReady
             val id = v.id
 
             when (id) {
                 R.id.playBtn -> {
                     doPauseResume()
                 }
+
                 R.id.rewindBtn -> {
-                    pos = playerControl.currentPosition
-                    pos -= 5000
-                    playerControl.seekTo(pos)
+                    playerControl.seekBack()
                     if (!playing) {
                         playerControl.pause() // necessary in some 2.3.x devices
                     }
                     setProgress()
                 }
-                R.id.forwardBtn -> {
-                    pos = playerControl.currentPosition
-                    pos += 15000
-                    playerControl.seekTo(pos)
 
+                R.id.forwardBtn -> {
+                    playerControl.seekForward()
                     if (!playing) {
                         playerControl.pause() // necessary in some 2.3.x devices
                     }
@@ -313,10 +311,10 @@ class MediaControlView(context: Context, attrs: AttributeSet?) :
         }
 
         playerControl?.let { playerControl ->
-            val duration = playerControl.duration.toLong()
+            val duration = playerControl.duration
             val newPosition = duration * progress / 1000L
-            playerControl.seekTo(newPosition.toInt())
-            binding.currentTimeText.text = formatTime(newPosition.toInt())
+            playerControl.seekTo(newPosition)
+            binding.currentTimeText.text = formatTime(newPosition)
         }
     }
 

+ 182 - 190
app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt

@@ -13,15 +13,12 @@
 package com.owncloud.android.ui.preview
 
 import android.app.Activity
-import android.content.BroadcastReceiver
-import android.content.Context
+import android.content.ComponentName
+import android.content.DialogInterface
 import android.content.Intent
-import android.content.IntentFilter
 import android.content.res.Configuration
-import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.graphics.drawable.Drawable
-import android.media.MediaMetadataRetriever
 import android.net.Uri
 import android.os.AsyncTask
 import android.os.Build
@@ -45,21 +42,29 @@ import androidx.core.view.WindowInsetsControllerCompat
 import androidx.core.view.marginBottom
 import androidx.core.view.updateLayoutParams
 import androidx.core.view.updatePadding
-import androidx.localbroadcastmanager.content.LocalBroadcastManager
 import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
 import androidx.media3.common.util.UnstableApi
 import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaController
+import androidx.media3.session.MediaSession
+import androidx.media3.session.SessionToken
 import androidx.media3.ui.DefaultTimeBar
 import androidx.media3.ui.PlayerView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.common.util.concurrent.ListenableFuture
+import com.google.common.util.concurrent.MoreExecutors
 import com.nextcloud.client.account.User
 import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.di.Injectable
 import com.nextcloud.client.jobs.BackgroundJobManager
 import com.nextcloud.client.jobs.download.FileDownloadHelper
+import com.nextcloud.client.media.BackgroundPlayerService
+import com.nextcloud.client.media.ErrorFormat
 import com.nextcloud.client.media.ExoplayerListener
 import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer
-import com.nextcloud.client.media.PlayerService
-import com.nextcloud.client.media.PlayerServiceConnection
 import com.nextcloud.client.network.ClientFactory
 import com.nextcloud.client.network.ClientFactory.CreationException
 import com.nextcloud.common.NextcloudClient
@@ -71,7 +76,6 @@ import com.nextcloud.utils.extensions.statusBarHeight
 import com.owncloud.android.R
 import com.owncloud.android.databinding.ActivityPreviewMediaBinding
 import com.owncloud.android.datamodel.OCFile
-import com.owncloud.android.datamodel.ThumbnailsCacheManager
 import com.owncloud.android.files.StreamMediaFileOperation
 import com.owncloud.android.lib.common.OwnCloudClient
 import com.owncloud.android.lib.common.operations.OnRemoteOperationListener
@@ -88,7 +92,6 @@ import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment
 import com.owncloud.android.ui.dialog.SendShareDialog
 import com.owncloud.android.ui.fragment.FileFragment
 import com.owncloud.android.ui.fragment.OCFileListFragment
-import com.owncloud.android.utils.BitmapUtils
 import com.owncloud.android.utils.DisplayUtils
 import com.owncloud.android.utils.ErrorMessageAdapter
 import com.owncloud.android.utils.MimeTypeUtil
@@ -107,6 +110,7 @@ import javax.inject.Inject
  * instantiation too.
  */
 @Suppress("TooManyFunctions")
+@OptIn(UnstableApi::class)
 class PreviewMediaActivity :
     FileActivity(),
     FileFragment.ContainerActivity,
@@ -117,9 +121,7 @@ class PreviewMediaActivity :
     private var user: User? = null
     private var savedPlaybackPosition: Long = 0
     private var autoplay = true
-    private val prepared = false
-    private var mediaPlayerServiceConnection: PlayerServiceConnection? = null
-    private var videoUri: Uri? = null
+    private var streamUri: Uri? = null
 
     @Inject
     lateinit var clientFactory: ClientFactory
@@ -132,7 +134,10 @@ class PreviewMediaActivity :
 
     private lateinit var binding: ActivityPreviewMediaBinding
     private var emptyListView: ViewGroup? = null
-    private var exoPlayer: ExoPlayer? = null
+    private var videoPlayer: ExoPlayer? = null
+    private var videoMediaSession: MediaSession? = null
+    private var audioMediaController: MediaController? = null
+    private var mediaControllerFuture: ListenableFuture<MediaController>? = null
     private var nextcloudClient: NextcloudClient? = null
     private lateinit var windowInsetsController: WindowInsetsControllerCompat
 
@@ -150,12 +155,36 @@ class PreviewMediaActivity :
         WindowCompat.setDecorFitsSystemWindows(window, false)
         applyWindowInsets()
         initArguments(savedInstanceState)
-        mediaPlayerServiceConnection = PlayerServiceConnection(this)
+
+        if (MimeTypeUtil.isVideo(file)) {
+            // release any background media session if exists
+            sendAudioSessionReleaseBroadcast()
+        } else if (MimeTypeUtil.isAudio(file)) {
+            val stopPlayer = Intent(BackgroundPlayerService.STOP_MEDIA_SESSION_BROADCAST_ACTION).apply {
+                setPackage(packageName)
+            }
+            sendBroadcast(stopPlayer)
+        }
+
         showMediaTypeViews()
         configureSystemBars()
         emptyListView = binding.emptyView.emptyListView
         showProgressLayout()
         addMarginForEmptyView()
+        if (file == null) {
+            return
+        }
+        if (MimeTypeUtil.isAudio(file)) {
+            setGenericThumbnail()
+            initializeAudioPlayer()
+        }
+    }
+
+    private fun sendAudioSessionReleaseBroadcast() {
+        val intent = Intent(BackgroundPlayerService.RELEASE_MEDIA_SESSION_BROADCAST_ACTION).apply {
+            setPackage(packageName)
+        }
+        sendBroadcast(intent)
     }
 
     private fun addMarginForEmptyView() {
@@ -173,25 +202,6 @@ class PreviewMediaActivity :
         emptyListView?.layoutParams = layoutParams
     }
 
-    private fun registerMediaControlReceiver() {
-        val filter = IntentFilter(MEDIA_CONTROL_READY_RECEIVER)
-        LocalBroadcastManager.getInstance(this).registerReceiver(mediaControlReceiver, filter)
-    }
-
-    private val mediaControlReceiver: BroadcastReceiver = object : BroadcastReceiver() {
-        override fun onReceive(context: Context, intent: Intent) {
-            intent.getBooleanExtra(PlayerService.IS_MEDIA_CONTROL_LAYOUT_READY, false).run {
-                if (this) {
-                    hideProgressLayout()
-                    mediaPlayerServiceConnection?.bind()
-                    setupAudioPlayerServiceConnection()
-                } else {
-                    showProgressLayout()
-                }
-            }
-        }
-    }
-
     private fun initArguments(savedInstanceState: Bundle?) {
         intent?.let {
             initWithIntent(it)
@@ -203,20 +213,6 @@ class PreviewMediaActivity :
         } else {
             initWithBundle(savedInstanceState)
         }
-
-        if (MimeTypeUtil.isAudio(file)) {
-            preparePreviewForAudioFile()
-        }
-    }
-
-    private fun preparePreviewForAudioFile() {
-        registerMediaControlReceiver()
-
-        if (file.isDown) {
-            return
-        }
-
-        requestForDownload(file)
     }
 
     private fun initWithIntent(intent: Intent) {
@@ -245,8 +241,6 @@ class PreviewMediaActivity :
 
         if (isFileVideo) {
             binding.root.setBackgroundColor(resources.getColor(R.color.black, null))
-        } else {
-            extractAndSetCoverArt(file)
         }
     }
 
@@ -265,17 +259,17 @@ class PreviewMediaActivity :
 
     private fun showProgressLayout() {
         binding.progress.visibility = View.VISIBLE
-        binding.mediaController.visibility = View.GONE
+        binding.audioControllerView.visibility = View.GONE
         binding.emptyView.emptyListView.visibility = View.GONE
     }
 
     private fun hideProgressLayout() {
         binding.progress.visibility = View.GONE
-        binding.mediaController.visibility = View.VISIBLE
+        binding.audioControllerView.visibility = View.VISIBLE
         binding.emptyView.emptyListView.visibility = View.VISIBLE
     }
 
-    private fun setVideoErrorMessage(headline: String, @StringRes message: Int) {
+    private fun setErrorMessage(headline: String, @StringRes message: Int) {
         binding.emptyView.run {
             emptyListViewHeadline.text = headline
             emptyListViewText.setText(message)
@@ -287,48 +281,6 @@ class PreviewMediaActivity :
         }
     }
 
-    /**
-     * tries to read the cover art from the audio file and sets it as cover art.
-     *
-     * @param file audio file with potential cover art
-     */
-    @Suppress("TooGenericExceptionCaught", "NestedBlockDepth")
-    private fun extractAndSetCoverArt(file: OCFile) {
-        if (!MimeTypeUtil.isAudio(file)) {
-            return
-        }
-
-        val bitmap = if (file.storagePath == null) {
-            getAudioThumbnail(file)
-        } else {
-            getThumbnail(file.storagePath) ?: getAudioThumbnail(file)
-        }
-
-        if (bitmap != null) {
-            binding.imagePreview.setImageBitmap(bitmap)
-        } else {
-            setGenericThumbnail()
-        }
-    }
-
-    @Suppress("TooGenericExceptionCaught")
-    private fun getThumbnail(storagePath: String?): Bitmap? {
-        return try {
-            MediaMetadataRetriever().run {
-                setDataSource(storagePath)
-                BitmapFactory.decodeByteArray(embeddedPicture, 0, embeddedPicture?.size ?: 0)
-            }
-        } catch (t: Throwable) {
-            BitmapUtils.drawableToBitmap(genericThumbnail())
-        }
-    }
-
-    private fun getAudioThumbnail(file: OCFile): Bitmap? {
-        return ThumbnailsCacheManager.getBitmapFromDiskCache(
-            ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId
-        )
-    }
-
     private fun setGenericThumbnail() {
         binding.imagePreview.setImageDrawable(genericThumbnail())
     }
@@ -356,17 +308,19 @@ class PreviewMediaActivity :
 
     private fun saveMediaInstanceState(bundle: Bundle) {
         bundle.run {
-            if (MimeTypeUtil.isVideo(file) && exoPlayer != null) {
-                exoPlayer?.let {
+            if (MimeTypeUtil.isVideo(file)) {
+                videoPlayer?.let {
                     savedPlaybackPosition = it.currentPosition
-                    autoplay = it.isPlaying
+                    autoplay = it.playWhenReady
+                }
+            } else {
+                audioMediaController?.let {
+                    savedPlaybackPosition = it.currentPosition
+                    autoplay = it.playWhenReady
                 }
-                putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition)
-                putBoolean(EXTRA_PLAYING, autoplay)
-            } else if (mediaPlayerServiceConnection != null && mediaPlayerServiceConnection!!.isConnected) {
-                putInt(EXTRA_PLAY_POSITION, mediaPlayerServiceConnection!!.currentPosition)
-                putBoolean(EXTRA_PLAYING, mediaPlayerServiceConnection!!.isPlaying)
             }
+            putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition)
+            putBoolean(EXTRA_PLAYING, autoplay)
         }
     }
 
@@ -375,42 +329,12 @@ class PreviewMediaActivity :
 
         Log_OC.v(TAG, "onStart")
 
-        if (file == null) {
-            return
+        if (MimeTypeUtil.isVideo(file)) {
+            initializeVideoPlayer()
         }
-
-        mediaPlayerServiceConnection?.bind()
-
-        if (MimeTypeUtil.isAudio(file)) {
-            setupAudioPlayerServiceConnection()
-        } else if (MimeTypeUtil.isVideo(file)) {
-            if (mediaPlayerServiceConnection?.isConnected == true) {
-                stopAudio()
-            }
-
-            if (exoPlayer != null) {
-                playVideo()
-            } else {
-                initNextcloudExoPlayer()
-            }
-        }
-    }
-
-    private fun setupAudioPlayerServiceConnection() {
-        binding.mediaController.run {
-            setMediaPlayer(mediaPlayerServiceConnection)
-            visibility = View.VISIBLE
-        }
-
-        user?.let {
-            mediaPlayerServiceConnection?.start(it, file, autoplay, savedPlaybackPosition)
-        }
-
-        binding.emptyView.emptyListView.visibility = View.GONE
-        binding.progress.visibility = View.GONE
     }
 
-    private fun initNextcloudExoPlayer() {
+    private fun initializeVideoPlayer() {
         val handler = Handler(Looper.getMainLooper())
         Executors.newSingleThreadExecutor().execute {
             try {
@@ -418,9 +342,10 @@ class PreviewMediaActivity :
 
                 nextcloudClient?.let { client ->
                     handler.post {
-                        exoPlayer = createNextcloudExoplayer(this, client)
+                        videoPlayer = createNextcloudExoplayer(this, client)
+                        videoMediaSession = MediaSession.Builder(this, videoPlayer as Player).build()
 
-                        exoPlayer?.let { player ->
+                        videoPlayer?.let { player ->
                             player.addListener(
                                 ExoplayerListener(
                                     this,
@@ -439,6 +364,102 @@ class PreviewMediaActivity :
         }
     }
 
+    private fun releaseVideoPlayer() {
+        videoPlayer?.let {
+            savedPlaybackPosition = it.currentPosition
+            autoplay = it.playWhenReady
+            it.release()
+            videoMediaSession?.release()
+        }
+        videoMediaSession = null
+        videoPlayer = null
+    }
+
+    @Suppress("TooGenericExceptionCaught")
+    private fun initializeAudioPlayer() {
+        val sessionToken = SessionToken(this, ComponentName(this, BackgroundPlayerService::class.java))
+        mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
+        mediaControllerFuture?.addListener(
+            {
+                try {
+                    audioMediaController = mediaControllerFuture?.get()
+                    playAudio()
+                    binding.audioControllerView.setMediaPlayer(audioMediaController)
+                } catch (e: Exception) {
+                    Log_OC.e(TAG, "exception raised while getting the media controller ${e.message}")
+                }
+            },
+            MoreExecutors.directExecutor()
+        )
+    }
+
+    @Suppress("TooGenericExceptionCaught")
+    private fun playAudio() {
+        if (file.isDown) {
+            prepareAudioPlayer(file.storageUri)
+        } else {
+            try {
+                LoadStreamUrl(this, user, clientFactory).execute(file.localId)
+            } catch (e: Exception) {
+                Log_OC.e(TAG, "Loading stream url for Audio not possible: $e")
+            }
+        }
+    }
+
+    private fun prepareAudioPlayer(uri: Uri) {
+        audioMediaController?.let { audioPlayer ->
+            audioPlayer.addListener(object : Player.Listener {
+
+                override fun onPlaybackStateChanged(playbackState: Int) {
+                    super.onPlaybackStateChanged(playbackState)
+                    if (playbackState == Player.STATE_READY) {
+                        hideProgressLayout()
+                        binding.emptyView.emptyListView.visibility = View.GONE
+                    }
+                }
+
+                override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
+                    super.onMediaMetadataChanged(mediaMetadata)
+                    val artworkBitmap = mediaMetadata.artworkData?.let { bytes: ByteArray ->
+                        BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+                    }
+                    if (artworkBitmap != null) {
+                        binding.imagePreview.setImageBitmap(artworkBitmap)
+                    }
+                }
+
+                override fun onPlayerError(error: PlaybackException) {
+                    super.onPlayerError(error)
+                    Log_OC.e(TAG, "Exoplayer error", error)
+                    val message = ErrorFormat.toString(this@PreviewMediaActivity, error)
+                    MaterialAlertDialogBuilder(this@PreviewMediaActivity)
+                        .setMessage(message)
+                        .setPositiveButton(R.string.common_ok) { _: DialogInterface?, _: Int ->
+                            audioPlayer.seekToDefaultPosition()
+                            audioPlayer.pause()
+                        }
+                        .setCancelable(false)
+                        .show()
+                }
+            })
+            val mediaItem = MediaItem.Builder()
+                .setUri(uri)
+                .setMediaMetadata(MediaMetadata.Builder().setTitle(file.fileName).build())
+                .build()
+            audioPlayer.setMediaItem(mediaItem)
+            audioPlayer.playWhenReady = autoplay
+            audioPlayer.seekTo(savedPlaybackPosition)
+            audioPlayer.prepare()
+        }
+    }
+
+    private fun releaseAudioPlayer() {
+        audioMediaController?.let { audioPlayer ->
+            audioPlayer.release()
+        }
+        audioMediaController = null
+    }
+
     private fun initWindowInsetsController() {
         windowInsetsController = WindowCompat.getInsetsController(
             window,
@@ -448,7 +469,6 @@ class PreviewMediaActivity :
         }
     }
 
-    @OptIn(markerClass = [UnstableApi::class])
     private fun applyWindowInsets() {
         val playerView = binding.exoplayerView
         val exoControls = playerView.findViewById<FrameLayout>(R.id.exo_bottom_bar)
@@ -477,7 +497,6 @@ class PreviewMediaActivity :
         }
     }
 
-    @OptIn(UnstableApi::class)
     private fun setupVideoView() {
         initWindowInsetsController()
         val type = WindowInsetsCompat.Type.systemBars()
@@ -495,14 +514,10 @@ class PreviewMediaActivity :
                     }
                 }
             )
-            it.player = exoPlayer
+            it.player = videoPlayer
         }
     }
 
-    private fun stopAudio() {
-        mediaPlayerServiceConnection?.stop()
-    }
-
     override fun onCreateOptionsMenu(menu: Menu?): Boolean {
         menuInflater.inflate(R.menu.custom_menu_placeholder, menu)
         return true
@@ -567,7 +582,7 @@ class PreviewMediaActivity :
             }
 
             R.id.action_remove_file -> {
-                exoPlayer?.stop()
+                videoPlayer?.pause()
                 val dialog = RemoveFilesDialogFragment.newInstance(file)
                 dialog.show(supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION)
             }
@@ -614,6 +629,7 @@ class PreviewMediaActivity :
             val removedFile = operation.file
             val fileAvailable: Boolean = storageManager.fileExists(removedFile.fileId)
             if (!fileAvailable && removedFile == file) {
+                sendAudioSessionReleaseBroadcast()
                 finish()
             }
         } else if (operation is SynchronizeFileOperation) {
@@ -670,30 +686,25 @@ class PreviewMediaActivity :
         setupVideoView()
 
         if (file.isDown) {
-            playVideoUri(file.storageUri)
+            prepareVideoPlayer(file.storageUri)
         } else {
             try {
                 LoadStreamUrl(this, user, clientFactory).execute(file.localId)
             } catch (e: Exception) {
-                Log_OC.e(TAG, "Loading stream url not possible: $e")
+                Log_OC.e(TAG, "Loading stream url for Video not possible: $e")
             }
         }
     }
 
-    private fun playVideoUri(uri: Uri) {
+    private fun prepareVideoPlayer(uri: Uri) {
         binding.progress.visibility = View.GONE
-
-        exoPlayer?.run {
-            setMediaItem(MediaItem.fromUri(uri))
+        val videoMediaItem = MediaItem.fromUri(uri)
+        videoPlayer?.run {
+            setMediaItem(videoMediaItem)
             playWhenReady = autoplay
+            seekTo(savedPlaybackPosition)
             prepare()
-
-            if (savedPlaybackPosition >= 0) {
-                seekTo(savedPlaybackPosition)
-            }
         }
-
-        autoplay = false
     }
 
     private class LoadStreamUrl(
@@ -728,11 +739,15 @@ class PreviewMediaActivity :
             val weakReference = previewMediaActivityWeakReference.get()
             weakReference?.apply {
                 if (uri != null) {
-                    videoUri = uri
-                    playVideoUri(uri)
+                    streamUri = uri
+                    if (MimeTypeUtil.isVideo(file)) {
+                        prepareVideoPlayer(uri)
+                    } else if (MimeTypeUtil.isAudio(file)) {
+                        prepareAudioPlayer(uri)
+                    }
                 } else {
                     emptyListView?.visibility = View.VISIBLE
-                    setVideoErrorMessage(
+                    setErrorMessage(
                         weakReference.getString(R.string.stream_not_possible_headline),
                         R.string.stream_not_possible_message
                     )
@@ -754,29 +769,16 @@ class PreviewMediaActivity :
     }
 
     override fun onDestroy() {
-        Log_OC.v(TAG, "onDestroy")
-
-        LocalBroadcastManager.getInstance(this).unregisterReceiver(mediaControlReceiver)
-
+        mediaControllerFuture?.let { MediaController.releaseFuture(it) }
         super.onDestroy()
-        exoPlayer?.run {
-            stop()
-            release()
-        }
+
+        Log_OC.v(TAG, "onDestroy")
     }
 
     override fun onStop() {
         Log_OC.v(TAG, "onStop")
 
-        file?.let {
-            if (MimeTypeUtil.isVideo(it) && exoPlayer != null && exoPlayer?.isPlaying == true) {
-                savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L
-            }
-        }
-
-        exoPlayer?.pause()
-        stopAudio()
-        mediaPlayerServiceConnection?.unbind()
+        releaseVideoPlayer()
         super.onStop()
     }
 
@@ -830,22 +832,12 @@ class PreviewMediaActivity :
 
     private fun stopPreview(stopAudio: Boolean) {
         if (MimeTypeUtil.isAudio(file) && stopAudio) {
-            mediaPlayerServiceConnection?.pause()
+            audioMediaController?.pause()
         } else if (MimeTypeUtil.isVideo(file)) {
-            savedPlaybackPosition = exoPlayer?.currentPosition ?: 0
-            exoPlayer?.stop()
+            releaseVideoPlayer()
         }
     }
 
-    val position: Long
-        get() {
-            if (prepared) {
-                savedPlaybackPosition = exoPlayer?.currentPosition ?: 0
-            }
-            Log_OC.v(TAG, "getting position: $savedPlaybackPosition")
-            return savedPlaybackPosition
-        }
-
     companion object {
         private val TAG = PreviewMediaActivity::class.java.simpleName
 

+ 33 - 136
app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt

@@ -17,8 +17,6 @@ import android.content.Context
 import android.content.Intent
 import android.content.res.Configuration
 import android.content.res.Resources
-import android.graphics.BitmapFactory
-import android.media.MediaMetadataRetriever
 import android.net.Uri
 import android.os.AsyncTask
 import android.os.Bundle
@@ -34,23 +32,23 @@ import android.view.View.OnTouchListener
 import android.view.ViewGroup
 import androidx.annotation.OptIn
 import androidx.annotation.StringRes
-import androidx.appcompat.content.res.AppCompatResources
-import androidx.core.graphics.drawable.DrawableCompat
 import androidx.core.view.MenuHost
 import androidx.core.view.MenuProvider
 import androidx.drawerlayout.widget.DrawerLayout
 import androidx.lifecycle.Lifecycle
 import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
 import androidx.media3.common.util.UnstableApi
 import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaSession
 import com.nextcloud.client.account.User
 import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.di.Injectable
 import com.nextcloud.client.jobs.BackgroundJobManager
 import com.nextcloud.client.jobs.download.FileDownloadHelper.Companion.instance
+import com.nextcloud.client.media.BackgroundPlayerService
 import com.nextcloud.client.media.ExoplayerListener
 import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer
-import com.nextcloud.client.media.PlayerServiceConnection
 import com.nextcloud.client.network.ClientFactory
 import com.nextcloud.client.network.ClientFactory.CreationException
 import com.nextcloud.common.NextcloudClient
@@ -61,7 +59,6 @@ import com.owncloud.android.MainApp
 import com.owncloud.android.R
 import com.owncloud.android.databinding.FragmentPreviewMediaBinding
 import com.owncloud.android.datamodel.OCFile
-import com.owncloud.android.datamodel.ThumbnailsCacheManager
 import com.owncloud.android.files.StreamMediaFileOperation
 import com.owncloud.android.lib.common.OwnCloudClient
 import com.owncloud.android.lib.common.utils.Log_OC
@@ -105,7 +102,6 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
     private var autoplay = true
     private var isLivePhoto = false
     private val prepared = false
-    private var mediaPlayerServiceConnection: PlayerServiceConnection? = null
 
     private var videoUri: Uri? = null
 
@@ -121,16 +117,22 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
     lateinit var binding: FragmentPreviewMediaBinding
     private var emptyListView: ViewGroup? = null
     private var exoPlayer: ExoPlayer? = null
+    private var mediaSession: MediaSession? = null
     private var nextcloudClient: NextcloudClient? = null
 
+    @OptIn(UnstableApi::class)
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
+        // release any background media session if exists
+        val intent = Intent(BackgroundPlayerService.RELEASE_MEDIA_SESSION_BROADCAST_ACTION).apply {
+            setPackage(requireActivity().packageName)
+        }
+        requireActivity().sendBroadcast(intent)
+
         arguments?.let {
             initArguments(it)
         }
-
-        mediaPlayerServiceConnection = PlayerServiceConnection(requireContext())
     }
 
     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -150,10 +152,6 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
 
         checkArgumentsAfterViewCreation(savedInstanceState)
 
-        if (file != null) {
-            prepareExoPlayerView()
-        }
-
         toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
         addMenuHost()
     }
@@ -198,62 +196,6 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
         binding.progress.visibility = View.GONE
     }
 
-    /**
-     * tries to read the cover art from the audio file and sets it as cover art.
-     *
-     * @param file audio file with potential cover art
-     */
-
-    @Suppress("TooGenericExceptionCaught")
-    private fun extractAndSetCoverArt(file: OCFile) {
-        if (!MimeTypeUtil.isAudio(file)) return
-
-        if (file.storagePath == null) {
-            setThumbnailForAudio(file)
-        } else {
-            try {
-                val mmr = MediaMetadataRetriever().apply {
-                    setDataSource(file.storagePath)
-                }
-
-                val data = mmr.embeddedPicture
-                if (data != null) {
-                    val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
-                    binding.imagePreview.setImageBitmap(bitmap) // associated cover art in bitmap
-                } else {
-                    setThumbnailForAudio(file)
-                }
-            } catch (t: Throwable) {
-                setGenericThumbnail()
-            }
-        }
-    }
-
-    private fun setThumbnailForAudio(file: OCFile) {
-        val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(
-            ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId
-        )
-
-        if (thumbnail != null) {
-            binding.imagePreview.setImageBitmap(thumbnail)
-        } else {
-            setGenericThumbnail()
-        }
-    }
-
-    /**
-     * Set generic icon (logo) as placeholder for thumbnail in preview.
-     */
-    private fun setGenericThumbnail() {
-        AppCompatResources.getDrawable(requireContext(), R.drawable.logo)?.let { logo ->
-            if (!resources.getBoolean(R.bool.is_branded_client)) {
-                // only colour logo of non-branded client
-                DrawableCompat.setTint(logo, resources.getColor(R.color.primary, requireContext().theme))
-            }
-            binding.imagePreview.setImageDrawable(logo)
-        }
-    }
-
     override fun onSaveInstanceState(outState: Bundle) {
         super.onSaveInstanceState(outState)
         file.logFileSize(TAG)
@@ -263,15 +205,10 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
             putParcelable(EXTRA_FILE, file)
             putParcelable(EXTRA_USER, user)
 
-            if (MimeTypeUtil.isVideo(file) && exoPlayer != null) {
-                savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L
-                autoplay = exoPlayer?.isPlaying ?: false
-                putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition)
-                putBoolean(EXTRA_PLAYING, autoplay)
-            } else if (mediaPlayerServiceConnection != null && mediaPlayerServiceConnection?.isConnected == true) {
-                putInt(EXTRA_PLAY_POSITION, mediaPlayerServiceConnection?.currentPosition ?: 0)
-                putBoolean(EXTRA_PLAYING, mediaPlayerServiceConnection?.isPlaying ?: false)
-            }
+            savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L
+            autoplay = exoPlayer?.isPlaying ?: false
+            putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition)
+            putBoolean(EXTRA_PLAYING, autoplay)
         }
     }
 
@@ -285,22 +222,11 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
             Log_OC.d(TAG, "File is null or fragment not attached to a context.")
             return
         }
-
-        mediaPlayerServiceConnection?.bind()
-
-        if (MimeTypeUtil.isAudio(file)) {
-            prepareForAudio()
-        } else if (MimeTypeUtil.isVideo(file)) {
-            prepareForVideo(context ?: MainApp.getAppContext())
-        }
+        prepareForVideo(context ?: MainApp.getAppContext())
     }
 
     @Suppress("DEPRECATION", "TooGenericExceptionCaught")
     private fun prepareForVideo(context: Context) {
-        if (mediaPlayerServiceConnection?.isConnected == true) {
-            // always stop player
-            stopAudio()
-        }
         if (exoPlayer != null) {
             playVideo()
         } else {
@@ -327,14 +253,22 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
             val listener = ExoplayerListener(context, binding.exoplayerView, it) { goBackToLivePhoto() }
             it.addListener(listener)
         }
+        // session id needs to be unique since this fragment is used in viewpager multiple fragments can exist at a time
+        mediaSession = MediaSession.Builder(
+            requireContext(),
+            exoPlayer as Player
+        ).setId(System.currentTimeMillis().toString()).build()
     }
 
-    private fun prepareForAudio() {
-        binding.mediaController.setMediaPlayer(mediaPlayerServiceConnection)
-        binding.mediaController.visibility = View.VISIBLE
-        mediaPlayerServiceConnection?.start(user!!, file, autoplay, savedPlaybackPosition)
-        binding.emptyView.emptyListView.visibility = View.GONE
-        binding.progress.visibility = View.GONE
+    private fun releaseVideoPlayer() {
+        exoPlayer?.let {
+            savedPlaybackPosition = it.currentPosition
+            autoplay = it.playWhenReady
+            it.release()
+            mediaSession?.release()
+        }
+        mediaSession = null
+        exoPlayer = null
     }
 
     private fun goBackToLivePhoto() {
@@ -346,17 +280,6 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
         requireActivity().supportFragmentManager.popBackStack()
     }
 
-    private fun prepareExoPlayerView() {
-        if (MimeTypeUtil.isVideo(file)) {
-            binding.exoplayerView.visibility = View.VISIBLE
-            binding.imagePreview.visibility = View.GONE
-        } else {
-            binding.exoplayerView.visibility = View.GONE
-            binding.imagePreview.visibility = View.VISIBLE
-            extractAndSetCoverArt(file)
-        }
-    }
-
     private fun showActionBar() {
         val currentActivity: Activity = requireActivity()
         if (currentActivity is PreviewImageActivity) {
@@ -374,10 +297,6 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
         }
     }
 
-    private fun stopAudio() {
-        mediaPlayerServiceConnection?.stop()
-    }
-
     private fun addMenuHost() {
         val menuHost: MenuHost = requireActivity()
 
@@ -490,12 +409,12 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
     }
 
     private fun seeDetails() {
-        stopPreview(false)
+        releaseVideoPlayer()
         containerActivity.showDetails(file)
     }
 
     private fun sendShareFile() {
-        stopPreview(false)
+        releaseVideoPlayer()
         containerActivity.fileOperationsHelper.sendShareFile(file)
     }
 
@@ -583,19 +502,7 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
     }
 
     override fun onStop() {
-        Log_OC.v(TAG, "onStop")
-        val file = file
-
-        if (MimeTypeUtil.isAudio(file) && mediaPlayerServiceConnection?.isPlaying == false) {
-            stopAudio()
-        } else if (MimeTypeUtil.isVideo(file) && exoPlayer != null && exoPlayer?.isPlaying == true) {
-            savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L
-            exoPlayer?.pause()
-        }
-
-        mediaPlayerServiceConnection?.unbind()
-        toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_UNLOCKED)
-
+        releaseVideoPlayer()
         super.onStop()
     }
 
@@ -641,19 +548,9 @@ class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable {
      * Opens the previewed file with an external application.
      */
     private fun openFile() {
-        stopPreview(true)
         containerActivity.fileOperationsHelper.openFile(file)
     }
 
-    private fun stopPreview(stopAudio: Boolean) {
-        if (stopAudio && mediaPlayerServiceConnection != null) {
-            mediaPlayerServiceConnection?.stop()
-        } else if (exoPlayer != null) {
-            savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L
-            exoPlayer?.stop()
-        }
-    }
-
     val position: Long
         get() {
             if (prepared) {

+ 1 - 1
app/src/main/res/layout/activity_preview_media.xml

@@ -42,7 +42,7 @@
         app:show_buffering="always" />
 
     <com.owncloud.android.media.MediaControlView
-        android:id="@+id/media_controller"
+        android:id="@+id/audio_controller_view"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:layout_alignParentBottom="true"

+ 0 - 17
app/src/main/res/layout/fragment_preview_media.xml

@@ -18,15 +18,6 @@
     android:gravity="center"
     tools:context=".ui.preview.PreviewMediaFragment">
 
-    <ImageView
-        android:id="@+id/image_preview"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:layout_gravity="center"
-        android:layout_margin="@dimen/standard_margin"
-        android:contentDescription="@string/preview_image_description"
-        android:src="@drawable/logo" />
-
     <androidx.media3.ui.PlayerView
         android:id="@+id/exoplayer_view"
         android:layout_width="match_parent"
@@ -34,14 +25,6 @@
         android:layout_gravity="center"
         app:show_buffering="always" />
 
-    <com.owncloud.android.media.MediaControlView
-        android:id="@+id/media_controller"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_alignParentBottom="true"
-        android:layout_margin="@dimen/standard_margin"
-        android:visibility="gone" />
-
     <FrameLayout
         android:id="@+id/progress"
         android:layout_width="match_parent"

+ 28 - 0
gradle/verification-metadata.xml

@@ -2548,6 +2548,14 @@
             <sha256 value="3670ba201f837bdce5ffaf4adc766a0d21cfd08db74efed5657513544c054eba" origin="Generated by Gradle" reason="Artifact is not signed"/>
          </artifact>
       </component>
+      <component group="androidx.media3" name="media3-session" version="1.4.0">
+         <artifact name="media3-session-1.4.0.aar">
+            <sha256 value="a5daaaea8fc9a87ebb4411f1d97bcf887069132068b3af15374205cfd458bb7c" origin="Generated by Gradle" reason="Artifact is not signed"/>
+         </artifact>
+         <artifact name="media3-session-1.4.0.module">
+            <sha256 value="6bccbca5b01eaa3fd0502300f3530ba1f1cdc952927a0a0f3fb1b1ae39860ed6" origin="Generated by Gradle" reason="Artifact is not signed"/>
+         </artifact>
+      </component>
       <component group="androidx.media3" name="media3-ui" version="1.2.0">
          <artifact name="media3-ui-1.2.0.aar">
             <sha256 value="fee39edbf615f9432f53af1cc9b20dd5706bfbc5dbd7fe581253a59eedd91482" origin="Generated by Gradle" reason="Artifact is not signed"/>
@@ -9718,6 +9726,11 @@
             <sha256 value="16e05e9f49621b87c53e69350140f3c46d42d966c67a933bdf4b063a2b1c8fc5" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="org.jacoco" name="org.jacoco.agent" version="0.8.12">
+         <artifact name="org.jacoco.agent-0.8.12.pom">
+            <sha256 value="0f9da994abd9827f957fc1ba7c5bad3fe918f62601c1d743f216b0615efe480e" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="org.jacoco" name="org.jacoco.ant" version="0.8.11">
          <artifact name="org.jacoco.ant-0.8.11.jar">
             <sha256 value="81d7eb8890d9be30a939612c295603541063529cdd03a53265aba74474b70b7c" origin="Generated by Gradle"/>
@@ -11041,6 +11054,11 @@
             <sha256 value="92eee24bc3c843e4881d46c1dd6505471ee3142facfb466b428cfea5a56c6b60" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="org.ow2.asm" name="asm" version="9.7">
+         <artifact name="asm-9.7.pom">
+            <sha256 value="de00115f1d84f3a0b2ee3a4b6f6192d066f86d185d67b9d1522f2c80feac5f00" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="org.ow2.asm" name="asm-analysis" version="9.2">
          <artifact name="asm-analysis-9.2.jar">
             <sha256 value="878fbe521731c072d14d2d65b983b1beae6ad06fda0007b6a8bae81f73f433c4" origin="Generated by Gradle"/>
@@ -11091,6 +11109,11 @@
             <sha256 value="a98ae4895334baf8ff86bd66516210dbd9a03f1a6e15e47dda82afcf6b53d77c" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="org.ow2.asm" name="asm-commons" version="9.7">
+         <artifact name="asm-commons-9.7.pom">
+            <sha256 value="5acee3ee7252ed90b8074c755d022787499a95fafff98ac4a685107c4da409b4" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="org.ow2.asm" name="asm-tree" version="9.2">
          <artifact name="asm-tree-9.2.jar">
             <sha256 value="aabf9bd23091a4ebfc109c1f3ee7cf3e4b89f6ba2d3f51c5243f16b3cffae011" origin="Generated by Gradle"/>
@@ -11115,6 +11138,11 @@
             <sha256 value="1bcb481d7fc16b955bb60ca07c8cfa2424bcee78bdc405bba31c7d6f5dc2d113" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="org.ow2.asm" name="asm-tree" version="9.7">
+         <artifact name="asm-tree-9.7.pom">
+            <sha256 value="a34ea1e3e4128c01038db43c6976e88c779cf5af84b0505da266dfe6965668ec" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="org.ow2.asm" name="asm-util" version="9.2">
          <artifact name="asm-util-9.2.jar">
             <sha256 value="ff5b3cd331ae8a9a804768280da98f50f424fef23dd3c788bb320e08c94ee598" origin="Generated by Gradle"/>