Browse Source

New media player service

Fixes #3061
Fixes #4412

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz 5 năm trước cách đây
mục cha
commit
2dab887782
24 tập tin đã thay đổi với 2088 bổ sung1219 xóa
  1. 2 0
      build.gradle
  2. 1 1
      src/main/AndroidManifest.xml
  3. 12 0
      src/main/java/com/nextcloud/client/di/AppModule.java
  4. 2 2
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  5. 45 0
      src/main/java/com/nextcloud/client/media/AudioFocus.kt
  6. 95 0
      src/main/java/com/nextcloud/client/media/AudioFocusManager.kt
  7. 94 0
      src/main/java/com/nextcloud/client/media/ErrorFormat.kt
  8. 49 0
      src/main/java/com/nextcloud/client/media/LoadUrlTask.kt
  9. 310 0
      src/main/java/com/nextcloud/client/media/Player.kt
  10. 3 0
      src/main/java/com/nextcloud/client/media/PlayerError.kt
  11. 148 0
      src/main/java/com/nextcloud/client/media/PlayerService.kt
  12. 134 0
      src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt
  13. 229 0
      src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt
  14. 6 0
      src/main/java/com/nextcloud/client/media/PlaylistItem.kt
  15. 111 91
      src/main/java/com/owncloud/android/media/MediaControlView.java
  16. 0 716
      src/main/java/com/owncloud/android/media/MediaService.java
  17. 0 181
      src/main/java/com/owncloud/android/media/MediaServiceBinder.java
  18. 7 42
      src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
  19. 32 179
      src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java
  20. 2 2
      src/main/java/com/owncloud/android/ui/preview/PreviewVideoActivity.java
  21. 0 5
      src/main/res/values/strings.xml
  22. 73 0
      src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt
  23. 50 0
      src/test/java/com/nextcloud/client/media/AudioFocusTest.kt
  24. 683 0
      src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt

+ 2 - 0
build.gradle

@@ -345,6 +345,8 @@ dependencies {
 //    androidJacocoAnt "org.jacoco:org.jacoco.core:${jacocoVersion}"
 //    androidJacocoAnt "org.jacoco:org.jacoco.report:${jacocoVersion}"
 //    androidJacocoAnt "org.jacoco:org.jacoco.agent:${jacocoVersion}"
+
+    implementation "com.github.stateless4j:stateless4j:2.6.0"
 }
 
 configurations.all {

+ 1 - 1
src/main/AndroidManifest.xml

@@ -307,7 +307,7 @@
         <service android:name=".services.OperationsService" />
         <service android:name=".files.services.FileDownloader" />
         <service android:name=".files.services.FileUploader" />
-        <service android:name=".media.MediaService" />
+        <service android:name="com.nextcloud.client.media.PlayerService"/>
 
         <activity
             android:name=".ui.activity.PassCodeActivity"

+ 12 - 0
src/main/java/com/nextcloud/client/di/AppModule.java

@@ -22,10 +22,12 @@ package com.nextcloud.client.di;
 
 import android.accounts.AccountManager;
 import android.app.Application;
+import android.app.NotificationManager;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Resources;
 import android.os.Handler;
+import android.media.AudioManager;
 
 import com.nextcloud.client.account.CurrentAccountProvider;
 import com.nextcloud.client.account.UserAccountManager;
@@ -146,4 +148,14 @@ class AppModule {
         Handler uiHandler = new Handler();
         return new ThreadPoolAsyncRunner(uiHandler, 4);
     }
+
+    @Provides
+    NotificationManager notificationManager(Context context) {
+        return (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
+    }
+
+    @Provides
+    AudioManager audioManager(Context context) {
+        return (AudioManager)context.getSystemService(Context.AUDIO_SERVICE);
+    }
 }

+ 2 - 2
src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -20,9 +20,9 @@
 
 package com.nextcloud.client.di;
 
-import com.nextcloud.client.errorhandling.ShowErrorActivity;
 import com.nextcloud.client.etm.EtmActivity;
 import com.nextcloud.client.logger.ui.LogsActivity;
+import com.nextcloud.client.media.PlayerService;
 import com.nextcloud.client.onboarding.FirstRunActivity;
 import com.nextcloud.client.onboarding.WhatsNewActivity;
 import com.owncloud.android.authentication.AuthenticatorActivity;
@@ -96,7 +96,6 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector abstract CopyToClipboardActivity copyToClipboardActivity();
     @ContributesAndroidInjector abstract DeepLinkLoginActivity deepLinkLoginActivity();
     @ContributesAndroidInjector abstract DrawerActivity drawerActivity();
-    @ContributesAndroidInjector abstract ShowErrorActivity errorShowActivity();
     @ContributesAndroidInjector abstract ErrorsWhileCopyingHandlerActivity errorsWhileCopyingHandlerActivity();
     @ContributesAndroidInjector abstract ExternalSiteWebView externalSiteWebView();
     @ContributesAndroidInjector abstract FileDisplayActivity fileDisplayActivity();
@@ -156,4 +155,5 @@ abstract class ComponentsModule {
 
     @ContributesAndroidInjector abstract AccountManagerService accountManagerService();
     @ContributesAndroidInjector abstract OperationsService operationsService();
+    @ContributesAndroidInjector abstract PlayerService playerService();
 }

+ 45 - 0
src/main/java/com/nextcloud/client/media/AudioFocus.kt

@@ -0,0 +1,45 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ *
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+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
+        }
+    }
+}

+ 95 - 0
src/main/java/com/nextcloud/client/media/AudioFocusManager.kt

@@ -0,0 +1,95 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ *
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Build
+
+/**
+ * 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
+) {
+
+    private val focusListener = object : AudioManager.OnAudioFocusChangeListener {
+        override fun onAudioFocusChange(focusChange: Int) {
+            val focus = when (focusChange) {
+                AudioManager.AUDIOFOCUS_GAIN -> AudioFocus.FOCUS
+                AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> AudioFocus.FOCUS
+                AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> AudioFocus.FOCUS
+                AudioManager.AUDIOFOCUS_LOSS -> AudioFocus.LOST
+                AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> AudioFocus.LOST
+                AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> AudioFocus.DUCK
+                else -> null
+            }
+            focus?.let { onFocusChange(it) }
+        }
+    }
+    private var focusRequest: AudioFocusRequest? = null
+
+    init {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
+                setWillPauseWhenDucked(true)
+                setOnAudioFocusChangeListener(focusListener)
+            }.build()
+        }
+    }
+
+    /**
+     * Request audio focus. Focus is reported via callback.
+     * If focus cannot be gained, lost of focus is reported.
+     */
+    fun requestFocus() {
+        val requestResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            focusRequest?.let { audioManger.requestAudioFocus(it) }
+        } else {
+            audioManger.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
+        }
+
+        if (requestResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+            focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN)
+        } else {
+            focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS)
+        }
+    }
+
+    /**
+     * Release audio focus. Loss of focus is reported via callback.
+     */
+    fun releaseFocus() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            focusRequest?.let {
+                audioManger.abandonAudioFocusRequest(it)
+            } ?: AudioManager.AUDIOFOCUS_REQUEST_FAILED
+        } else {
+            audioManger.abandonAudioFocus(focusListener)
+        }
+        focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS)
+    }
+}

+ 94 - 0
src/main/java/com/nextcloud/client/media/ErrorFormat.kt

@@ -0,0 +1,94 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author David A. Velasco
+ * @author masensio
+ * @author Chris Narkiewicz
+ * Copyright (C) 2013 David A. Velasco
+ * Copyright (C) 2016 masensio
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import android.content.Context
+import android.media.MediaPlayer
+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"
+    }
+}

+ 49 - 0
src/main/java/com/nextcloud/client/media/LoadUrlTask.kt

@@ -0,0 +1,49 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * @author Tobias Kaminsky
+ *
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * Copyright (C) 2018 Tobias Kaminsky
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+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: String,
+    private val onResult: (String?) -> Unit
+) : AsyncTask<Void, Void, String>() {
+
+    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)
+        }
+    }
+}

+ 310 - 0
src/main/java/com/nextcloud/client/media/Player.kt

@@ -0,0 +1,310 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import android.accounts.Account
+import android.content.Context
+import android.media.AudioManager
+import android.media.MediaPlayer
+import android.os.PowerManager
+import android.widget.MediaController
+import com.nextcloud.client.media.PlayerStateMachine.Event
+import com.nextcloud.client.media.PlayerStateMachine.State
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudAccount
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
+import com.owncloud.android.lib.common.utils.Log_OC
+
+@Suppress("TooManyFunctions")
+internal class Player(
+    private val context: Context,
+    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: Int = 0
+    private var autoPlay = true
+    private var account: Account? = 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
+                    account = it.account
+                    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()")
+            if (playedFile == null) {
+                throw IllegalStateException("File not set.")
+            }
+            playedFile?.let {
+                val client = buildClient()
+                val task = LoadUrlTask(client, it.remoteId, 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
+            account = 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()")
+            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)
+        }
+    }
+
+    // this should be refactored into a proper, injectable factory
+    private fun buildClient(): OwnCloudClient {
+        val account = this.account
+        if (account != null) {
+            val ocAccount = OwnCloudAccount(account, context)
+            return OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
+        } else {
+            throw IllegalArgumentException("Account not set")
+        }
+    }
+
+    private fun trace(fmt: String, vararg args: Any?) {
+        Log_OC.v(javaClass.simpleName, fmt.format(args))
+    }
+
+    // region Media player controls
+
+    override fun isPlaying(): Boolean {
+        return stateMachine.isInState(State.PLAYING)
+    }
+
+    override fun canSeekForward(): Boolean {
+        return duration > MIN_DURATION_ALLOWING_SEEK
+    }
+
+    override fun canSeekBackward(): Boolean {
+        return 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 {
+        return 0
+    }
+
+    override fun seekTo(pos: Int) {
+        if (stateMachine.isInState(State.PLAYING)) {
+            mediaPlayer?.seekTo(pos)
+        }
+    }
+
+    override fun getCurrentPosition(): Int {
+        return mediaPlayer?.currentPosition ?: 0
+    }
+
+    override fun start() {
+        stateMachine.post(Event.PLAY)
+    }
+
+    override fun getAudioSessionId(): Int {
+        return 0
+    }
+
+    override fun canPause(): Boolean {
+        return stateMachine.isInState(State.PLAYING)
+    }
+
+    // endregion
+}

+ 3 - 0
src/main/java/com/nextcloud/client/media/PlayerError.kt

@@ -0,0 +1,3 @@
+package com.nextcloud.client.media
+
+data class PlayerError(val message: String)

+ 148 - 0
src/main/java/com/nextcloud/client/media/PlayerService.kt

@@ -0,0 +1,148 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import android.accounts.Account
+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 com.owncloud.android.R
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.ui.notifications.NotificationUtils
+import com.owncloud.android.utils.ThemeUtils
+import dagger.android.AndroidInjection
+import java.lang.IllegalArgumentException
+import javax.inject.Inject
+
+class PlayerService : Service() {
+
+    companion object {
+        const val EXTRA_ACCOUNT = "ACCOUNT"
+        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_STOP_FILE = "STOP_FILE"
+    }
+
+    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) {
+            startForeground(file)
+        }
+
+        override fun onStart() {
+            // empty
+        }
+
+        override fun onPause() {
+            // empty
+        }
+
+        override fun onStop() {
+            stopForeground(true)
+        }
+
+        override fun onError(error: PlayerError) {
+            Toast.makeText(this@PlayerService, error.message, Toast.LENGTH_SHORT).show()
+        }
+    }
+
+    @Inject
+    protected lateinit var audioManager: AudioManager
+
+    private lateinit var player: Player
+    private lateinit var notificationBuilder: NotificationCompat.Builder
+
+    override fun onCreate() {
+        super.onCreate()
+        AndroidInjection.inject(this)
+        player = Player(applicationContext, playerListener, audioManager)
+        notificationBuilder = NotificationCompat.Builder(this)
+        notificationBuilder.color = ThemeUtils.primaryColor(this)
+        val stop = Intent(this, PlayerService::class.java)
+        stop.action = ACTION_STOP
+        val pendingStop = PendingIntent.getService(this, 0, stop, 0)
+        notificationBuilder.addAction(0, "STOP", pendingStop)
+    }
+
+    override fun onBind(intent: Intent?): IBinder? {
+        return Binder(this)
+    }
+
+    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+        when (intent.action) {
+            ACTION_PLAY -> onActionPlay(intent)
+            ACTION_STOP -> onActionStop()
+            ACTION_STOP_FILE -> onActionStopFile(intent.extras)
+        }
+        return START_NOT_STICKY
+    }
+
+    private fun onActionPlay(intent: Intent) {
+        val account: Account = intent.getParcelableExtra(EXTRA_ACCOUNT)
+        val file: OCFile = intent.getParcelableExtra(EXTRA_FILE)
+        val startPos = intent.getIntExtra(EXTRA_START_POSITION_MS, 0)
+        val autoPlay = intent.getBooleanExtra(EXTRA_AUTO_PLAY, true)
+        val item = PlaylistItem(file = file, startPositionMs = startPos, autoPlay = autoPlay, account = account)
+        player.play(item)
+    }
+
+    private fun onActionStop() {
+        player.stop()
+    }
+
+    private fun onActionStopFile(args: Bundle?) {
+        val file: OCFile = args?.getParcelable(EXTRA_FILE) ?: throw IllegalArgumentException("Missing file argument")
+        player.stop(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.setSmallIcon(R.drawable.ic_play_arrow)
+        notificationBuilder.setWhen(System.currentTimeMillis())
+        notificationBuilder.setOngoing(true)
+        notificationBuilder.setContentTitle(ticker)
+        notificationBuilder.setContentText(content)
+
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+            notificationBuilder.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA)
+        }
+
+        startForeground(R.string.media_notif_ticker, notificationBuilder.build())
+    }
+}

+ 134 - 0
src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt

@@ -0,0 +1,134 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import android.accounts.Account
+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 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(account: Account, file: OCFile, playImmediately: Boolean, position: Int) {
+        val i = Intent(context, PlayerService::class.java)
+        i.putExtra(PlayerService.EXTRA_ACCOUNT, account)
+        i.putExtra(PlayerService.EXTRA_FILE, file)
+        i.putExtra(PlayerService.EXTRA_AUTO_PLAY, playImmediately)
+        i.putExtra(PlayerService.EXTRA_START_POSITION_MS, position)
+        i.action = PlayerService.ACTION_PLAY
+        context.startService(i)
+    }
+
+    fun stop(file: OCFile) {
+        val i = Intent(context, PlayerService::class.java)
+        i.putExtra(PlayerService.EXTRA_FILE, file)
+        i.action = PlayerService.ACTION_STOP_FILE
+        context.startService(i)
+    }
+
+    fun stop() {
+        val i = Intent(context, PlayerService::class.java)
+        i.action = PlayerService.ACTION_STOP
+        context.startService(i)
+    }
+
+    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 {
+        return binder?.player?.isPlaying ?: false
+    }
+
+    override fun canSeekForward(): Boolean {
+        return binder?.player?.canSeekForward() ?: false
+    }
+
+    override fun getDuration(): Int {
+        return binder?.player?.duration ?: 0
+    }
+
+    override fun pause() {
+        binder?.player?.pause()
+    }
+
+    override fun getBufferPercentage(): Int {
+        return binder?.player?.bufferPercentage ?: 0
+    }
+
+    override fun seekTo(pos: Int) {
+        binder?.player?.seekTo(pos)
+    }
+
+    override fun getCurrentPosition(): Int {
+        return binder?.player?.currentPosition ?: 0
+    }
+
+    override fun canSeekBackward(): Boolean {
+        return binder?.player?.canSeekBackward() ?: false
+    }
+
+    override fun start() {
+        binder?.player?.start()
+    }
+
+    override fun getAudioSessionId(): Int {
+        return 0
+    }
+
+    override fun canPause(): Boolean {
+        return binder?.player?.canPause() ?: false
+    }
+
+    // endregion
+}

+ 229 - 0
src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt

@@ -0,0 +1,229 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+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: <onReleaseFocus
+ *      state PLAYING {
+ *          [*] -r-> AWAIT_FOCUS
+ *          AWAIT_FOCUS --> FOCUSED: FOCUS_GAIN\nonStartPlayback()
+ *          FOCUSED -l-> DUCKED: FOCUS_DUCK
+ *          DUCKED: >onAudioDuck(true)\n<onAudioDuck(false)
+ *          DUCKED -r-> 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<Event>()
+    private var isProcessing = false
+    private val stateMachine: StateMachine<State, Event>
+
+    /**
+     * 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<State, Event>()
+
+        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<State, Event>) {
+        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 {
+        return 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
+        }
+    }
+}

+ 6 - 0
src/main/java/com/nextcloud/client/media/PlaylistItem.kt

@@ -0,0 +1,6 @@
+package com.nextcloud.client.media
+
+import android.accounts.Account
+import com.owncloud.android.datamodel.OCFile
+
+data class PlaylistItem(val file: OCFile, val startPositionMs: Int, val autoPlay: Boolean, val account: Account)

+ 111 - 91
src/main/java/com/owncloud/android/media/MediaControlView.java

@@ -55,17 +55,17 @@ import java.util.Locale;
  */
 public class MediaControlView extends FrameLayout implements OnClickListener, OnSeekBarChangeListener {
     private static final String TAG = MediaControlView.class.getSimpleName();
-
-    private MediaPlayerControl mPlayer;
-    private View mRoot;
-    private ProgressBar mProgress;
-    private TextView mEndTime;
-    private TextView mCurrentTime;
-    private boolean mDragging;
     private static final int SHOW_PROGRESS = 1;
-    private ImageButton mPauseButton;
-    private ImageButton mFfwdButton;
-    private ImageButton mRewButton;
+
+    private MediaPlayerControl playerControl;
+    private View root;
+    private ProgressBar progressBar;
+    private TextView endTime;
+    private TextView currentTime;
+    private boolean isDragging;
+    private ImageButton pauseButton;
+    private ImageButton forwardButton;
+    private ImageButton rewindButton;
 
     public MediaControlView(Context context, AttributeSet attrs) {
         super(context, attrs);
@@ -75,9 +75,9 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
                 ViewGroup.LayoutParams.MATCH_PARENT
         );
         LayoutInflater inflate = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-        mRoot = inflate.inflate(R.layout.media_control, null);
-        initControllerView(mRoot);
-        addView(mRoot, frameParams);
+        root = inflate.inflate(R.layout.media_control, null);
+        initControllerView(root);
+        addView(root, frameParams);
 
         setFocusable(true);
         setFocusableInTouchMode(true);
@@ -91,47 +91,50 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
     }
 
     public void setMediaPlayer(MediaPlayerControl player) {
-        mPlayer = player;
-        mHandler.sendEmptyMessage(SHOW_PROGRESS);
-        updatePausePlay();
+        playerControl = player;
+        handler.sendEmptyMessage(SHOW_PROGRESS);
+        handler.postDelayed(()-> {
+            updatePausePlay();
+            setProgress();
+        }, 100);
     }
 
     public void stopMediaPlayerMessages() {
-        mHandler.removeMessages(SHOW_PROGRESS);
+        handler.removeMessages(SHOW_PROGRESS);
     }
 
 
     private void initControllerView(View v) {
-        mPauseButton = v.findViewById(R.id.playBtn);
-        if (mPauseButton != null) {
-            mPauseButton.requestFocus();
-            mPauseButton.setOnClickListener(this);
+        pauseButton = v.findViewById(R.id.playBtn);
+        if (pauseButton != null) {
+            pauseButton.requestFocus();
+            pauseButton.setOnClickListener(this);
         }
 
-        mFfwdButton = v.findViewById(R.id.forwardBtn);
-        if (mFfwdButton != null) {
-            mFfwdButton.setOnClickListener(this);
+        forwardButton = v.findViewById(R.id.forwardBtn);
+        if (forwardButton != null) {
+            forwardButton.setOnClickListener(this);
         }
 
-        mRewButton = v.findViewById(R.id.rewindBtn);
-        if (mRewButton != null) {
-            mRewButton.setOnClickListener(this);
+        rewindButton = v.findViewById(R.id.rewindBtn);
+        if (rewindButton != null) {
+            rewindButton.setOnClickListener(this);
         }
 
-        mProgress = v.findViewById(R.id.progressBar);
-        if (mProgress != null) {
-            if (mProgress instanceof SeekBar) {
-                SeekBar seeker = (SeekBar) mProgress;
+        progressBar = v.findViewById(R.id.progressBar);
+        if (progressBar != null) {
+            if (progressBar instanceof SeekBar) {
+                SeekBar seeker = (SeekBar) progressBar;
                 ThemeUtils.colorHorizontalSeekBar(seeker, getContext());
                 seeker.setOnSeekBarChangeListener(this);
             } else {
-                ThemeUtils.colorHorizontalProgressBar(mProgress, ThemeUtils.primaryAccentColor(getContext()));
+                ThemeUtils.colorHorizontalProgressBar(progressBar, ThemeUtils.primaryAccentColor(getContext()));
             }
-            mProgress.setMax(1000);
+            progressBar.setMax(1000);
         }
 
-        mEndTime = v.findViewById(R.id.totalTimeText);
-        mCurrentTime = v.findViewById(R.id.currentTimeText);
+        endTime = v.findViewById(R.id.totalTimeText);
+        currentTime = v.findViewById(R.id.currentTimeText);
     }
 
     /**
@@ -140,14 +143,14 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
      */
     private void disableUnsupportedButtons() {
         try {
-            if (mPauseButton != null && !mPlayer.canPause()) {
-                mPauseButton.setEnabled(false);
+            if (pauseButton != null && !playerControl.canPause()) {
+                pauseButton.setEnabled(false);
             }
-            if (mRewButton != null && !mPlayer.canSeekBackward()) {
-                mRewButton.setEnabled(false);
+            if (rewindButton != null && !playerControl.canSeekBackward()) {
+                rewindButton.setEnabled(false);
             }
-            if (mFfwdButton != null && !mPlayer.canSeekForward()) {
-                mFfwdButton.setEnabled(false);
+            if (forwardButton != null && !playerControl.canSeekForward()) {
+                forwardButton.setEnabled(false);
             }
         } catch (IncompatibleClassChangeError ex) {
             // We were given an old version of the interface, that doesn't have
@@ -159,13 +162,14 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
     }
 
 
-    private Handler mHandler = new Handler() {
+    private Handler handler = new Handler() {
         @Override
         public void handleMessage(Message msg) {
             int pos;
             if (msg.what == SHOW_PROGRESS) {
+                updatePausePlay();
                 pos = setProgress();
-                if (!mDragging) {
+                if (!isDragging) {
                     msg = obtainMessage(SHOW_PROGRESS);
                     sendMessageDelayed(msg, 1000 - (pos % 1000));
                 }
@@ -173,7 +177,7 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
         }
     };
 
-    private String stringForTime(int timeMs) {
+    private String formatTime(int timeMs) {
         int totalSeconds = timeMs / 1000;
 
         int seconds = totalSeconds % 60;
@@ -190,26 +194,27 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
     }
 
     private int setProgress() {
-        if (mPlayer == null || mDragging) {
+        if (playerControl == null || isDragging) {
             return 0;
         }
-        int position = mPlayer.getCurrentPosition();
-        int duration = mPlayer.getDuration();
-        if (mProgress != null) {
+        int position = playerControl.getCurrentPosition();
+        int duration = playerControl.getDuration();
+        if (progressBar != null) {
             if (duration > 0) {
                 // use long to avoid overflow
                 long pos = 1000L * position / duration;
-                mProgress.setProgress((int) pos);
+                progressBar.setProgress((int) pos);
             }
-            int percent = mPlayer.getBufferPercentage();
-            mProgress.setSecondaryProgress(percent * 10);
+            int percent = playerControl.getBufferPercentage();
+            progressBar.setSecondaryProgress(percent * 10);
         }
 
-        if (mEndTime != null) {
-            mEndTime.setText(stringForTime(duration));
+        if (endTime != null) {
+            String endTime = duration > 0 ? formatTime(duration) : "--:--";
+            this.endTime.setText(endTime);
         }
-        if (mCurrentTime != null) {
-            mCurrentTime.setText(stringForTime(position));
+        if (currentTime != null) {
+            currentTime.setText(formatTime(position));
         }
         return position;
     }
@@ -226,21 +231,21 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
             if (uniqueDown) {
                 doPauseResume();
                 //show(sDefaultTimeout);
-                if (mPauseButton != null) {
-                    mPauseButton.requestFocus();
+                if (pauseButton != null) {
+                    pauseButton.requestFocus();
                 }
             }
             return true;
         } else if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY) {
-            if (uniqueDown && !mPlayer.isPlaying()) {
-                mPlayer.start();
+            if (uniqueDown && !playerControl.isPlaying()) {
+                playerControl.start();
                 updatePausePlay();
             }
             return true;
         } else if (keyCode == KeyEvent.KEYCODE_MEDIA_STOP
                 || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) {
-            if (uniqueDown && mPlayer.isPlaying()) {
-                mPlayer.pause();
+            if (uniqueDown && playerControl.isPlaying()) {
+                playerControl.pause();
                 updatePausePlay();
             }
             return true;
@@ -250,39 +255,54 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
     }
 
     public void updatePausePlay() {
-        if (mRoot == null || mPauseButton == null) {
+        if (root == null || pauseButton == null) {
             return;
         }
 
-        if (mPlayer.isPlaying()) {
-            mPauseButton.setImageResource(android.R.drawable.ic_media_pause);
+        if (playerControl.isPlaying()) {
+            pauseButton.setImageResource(android.R.drawable.ic_media_pause);
         } else {
-            mPauseButton.setImageResource(android.R.drawable.ic_media_play);
+            pauseButton.setImageResource(android.R.drawable.ic_media_play);
         }
+
+        final boolean canSeekFfd = playerControl.canSeekForward();
+        if (canSeekFfd) {
+            forwardButton.setVisibility(View.VISIBLE);
+        } else {
+            forwardButton.setVisibility(View.INVISIBLE);
+        }
+
+        final boolean canSeekBwd = playerControl.canSeekBackward();
+        if (canSeekBwd) {
+            rewindButton.setVisibility(View.VISIBLE);
+        } else {
+            rewindButton.setVisibility(View.INVISIBLE);
+        }
+
     }
 
     private void doPauseResume() {
-        if (mPlayer.isPlaying()) {
-            mPlayer.pause();
+        if (playerControl.isPlaying()) {
+            playerControl.pause();
         } else {
-            mPlayer.start();
+            playerControl.start();
         }
         updatePausePlay();
     }
 
     @Override
     public void setEnabled(boolean enabled) {
-        if (mPauseButton != null) {
-            mPauseButton.setEnabled(enabled);
+        if (pauseButton != null) {
+            pauseButton.setEnabled(enabled);
         }
-        if (mFfwdButton != null) {
-            mFfwdButton.setEnabled(enabled);
+        if (forwardButton != null) {
+            forwardButton.setEnabled(enabled);
         }
-        if (mRewButton != null) {
-            mRewButton.setEnabled(enabled);
+        if (rewindButton != null) {
+            rewindButton.setEnabled(enabled);
         }
-        if (mProgress != null) {
-            mProgress.setEnabled(enabled);
+        if (progressBar != null) {
+            progressBar.setEnabled(enabled);
         }
         disableUnsupportedButtons();
         super.setEnabled(enabled);
@@ -291,7 +311,7 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
     @Override
     public void onClick(View v) {
         int pos;
-        boolean playing = mPlayer.isPlaying();
+        boolean playing = playerControl.isPlaying();
         switch (v.getId()) {
 
             case R.id.playBtn:
@@ -299,21 +319,21 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
                 break;
 
             case R.id.rewindBtn:
-                pos = mPlayer.getCurrentPosition();
+                pos = playerControl.getCurrentPosition();
                 pos -= 5000;
-                mPlayer.seekTo(pos);
+                playerControl.seekTo(pos);
                 if (!playing) {
-                    mPlayer.pause();  // necessary in some 2.3.x devices
+                    playerControl.pause();  // necessary in some 2.3.x devices
                 }
                 setProgress();
                 break;
 
             case R.id.forwardBtn:
-                pos = mPlayer.getCurrentPosition();
+                pos = playerControl.getCurrentPosition();
                 pos += 15000;
-                mPlayer.seekTo(pos);
+                playerControl.seekTo(pos);
                 if (!playing) {
-                    mPlayer.pause(); // necessary in some 2.3.x devices
+                    playerControl.pause(); // necessary in some 2.3.x devices
                 }
                 setProgress();
                 break;
@@ -329,11 +349,11 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
             return;
         }
 
-        long duration = mPlayer.getDuration();
+        long duration = playerControl.getDuration();
         long newPosition = (duration * progress) / 1000L;
-        mPlayer.seekTo((int) newPosition);
-        if (mCurrentTime != null) {
-            mCurrentTime.setText(stringForTime((int) newPosition));
+        playerControl.seekTo((int) newPosition);
+        if (currentTime != null) {
+            currentTime.setText(formatTime((int) newPosition));
         }
     }
 
@@ -344,8 +364,8 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
      */
     @Override
     public void onStartTrackingTouch(SeekBar seekBar) {
-        mDragging = true;                           // monitors the duration of dragging 
-        mHandler.removeMessages(SHOW_PROGRESS);     // grants no more updates with media player progress while dragging 
+        isDragging = true;                           // monitors the duration of dragging
+        handler.removeMessages(SHOW_PROGRESS);     // grants no more updates with media player progress while dragging
     }
 
 
@@ -354,10 +374,10 @@ public class MediaControlView extends FrameLayout implements OnClickListener, On
      */
     @Override
     public void onStopTrackingTouch(SeekBar seekBar) {
-        mDragging = false;
+        isDragging = false;
         setProgress();
         updatePausePlay();
-        mHandler.sendEmptyMessage(SHOW_PROGRESS);    // grants future updates with media player progress 
+        handler.sendEmptyMessage(SHOW_PROGRESS);    // grants future updates with media player progress
     }
 
     @Override

+ 0 - 716
src/main/java/com/owncloud/android/media/MediaService.java

@@ -1,716 +0,0 @@
-/*
- * ownCloud Android client application
- *
- * @author David A. Velasco
- * Copyright (C) 2016 ownCloud Inc.
- *
- * @author Tobias Kaminsky
- * Copyright (C) 2018 Tobias Kaminsky
- * Copyright (C) 2018 Nextcloud GmbH.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.owncloud.android.media;
-
-import android.accounts.Account;
-import android.accounts.AuthenticatorException;
-import android.accounts.OperationCanceledException;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.media.AudioManager;
-import android.media.MediaPlayer;
-import android.media.MediaPlayer.OnCompletionListener;
-import android.media.MediaPlayer.OnErrorListener;
-import android.media.MediaPlayer.OnPreparedListener;
-import android.net.wifi.WifiManager;
-import android.net.wifi.WifiManager.WifiLock;
-import android.os.AsyncTask;
-import android.os.IBinder;
-import android.os.PowerManager;
-import android.widget.Toast;
-
-import com.owncloud.android.R;
-import com.owncloud.android.datamodel.OCFile;
-import com.owncloud.android.files.StreamMediaFileOperation;
-import com.owncloud.android.lib.common.OwnCloudAccount;
-import com.owncloud.android.lib.common.OwnCloudClient;
-import com.owncloud.android.lib.common.OwnCloudClientManagerFactory;
-import com.owncloud.android.lib.common.accounts.AccountUtils;
-import com.owncloud.android.lib.common.operations.RemoteOperationResult;
-import com.owncloud.android.lib.common.utils.Log_OC;
-import com.owncloud.android.ui.activity.FileActivity;
-import com.owncloud.android.ui.activity.FileDisplayActivity;
-import com.owncloud.android.ui.notifications.NotificationUtils;
-import com.owncloud.android.utils.ThemeUtils;
-
-import java.io.IOException;
-import java.lang.ref.WeakReference;
-
-import androidx.core.app.NotificationCompat;
-import lombok.Getter;
-import lombok.Setter;
-
-
-/**
- * Service that handles media playback, both audio and video.
- *
- * Waits for Intents which signal the service to perform specific operations: Play, Pause,
- * Rewind, etc.
- */
-public class MediaService extends Service implements OnCompletionListener, OnPreparedListener,
-        OnErrorListener, AudioManager.OnAudioFocusChangeListener {
-
-    private static final String TAG = MediaService.class.getSimpleName();
-
-    private static final String MY_PACKAGE = MediaService.class.getPackage() != null ?
-            MediaService.class.getPackage().getName() : "com.owncloud.android.media";
-
-    /// Intent actions that we are prepared to handle
-    public static final String ACTION_PLAY_FILE = MY_PACKAGE + ".action.PLAY_FILE";
-    public static final String ACTION_STOP_ALL = MY_PACKAGE + ".action.STOP_ALL";
-
-    /// PreferenceKeys to add extras to the action
-    public static final String EXTRA_FILE = MY_PACKAGE + ".extra.FILE";
-    public static final String EXTRA_ACCOUNT = MY_PACKAGE + ".extra.ACCOUNT";
-    public static final String EXTRA_START_POSITION = MY_PACKAGE + ".extra.START_POSITION";
-    public static final String EXTRA_PLAY_ON_LOAD = MY_PACKAGE + ".extra.PLAY_ON_LOAD";
-
-
-    /** Error code for specific messages - see regular error codes at {@link MediaPlayer} */
-    public static final int OC_MEDIA_ERROR = 0;
-
-    /** Time To keep the control panel visible when the user does not use it */
-    public static final int MEDIA_CONTROL_SHORT_LIFE = 4000;
-
-    /** Time To keep the control panel visible when the user does not use it */
-    public static final int MEDIA_CONTROL_PERMANENT = 0;
-
-    /** Volume to set when audio focus is lost and ducking is allowed */
-    private static final float DUCK_VOLUME = 0.1f;
-
-    /** Media player instance */
-    @Getter private MediaPlayer player;
-
-    /** Reference to the system AudioManager */
-    private AudioManager audioManager;
-
-
-    /** Values to indicate the state of the service */
-    enum State {
-        STOPPED,
-        PREPARING,
-        PLAYING,
-        PAUSED
-    }
-
-    /** Current state */
-    @Getter private State state = State.STOPPED;
-
-    /** Possible focus values */
-    enum AudioFocus {
-        NO_FOCUS,
-        NO_FOCUS_CAN_DUCK,
-        FOCUS
-    }
-
-    /** Current focus state */
-    private AudioFocus audioFocus = AudioFocus.NO_FOCUS;
-
-    /** Wifi lock kept to prevents the device from shutting off the radio when streaming a file. */
-    private WifiLock wifiLock;
-
-    private static final String MEDIA_WIFI_LOCK_TAG = MY_PACKAGE + ".WIFI_LOCK";
-
-    /** Notification to keep in the notification bar while a song is playing */
-    private NotificationManager notificationManager;
-
-    /** File being played */
-    @Getter private OCFile currentFile;
-
-    /** Account holding the file being played */
-    private Account account;
-
-    /** Flag signaling if the audio should be played immediately when the file is prepared */
-    protected boolean playOnPrepared;
-
-    /** Position, in milliseconds, where the audio should be started */
-    private int startPosition;
-
-    /** Interface to access the service through binding */
-    private IBinder binder;
-
-    /** Control panel shown to the user to control the playback, to register through binding */
-    @Getter @Setter private MediaControlView mediaController;
-
-    /** Notification builder to create notifications, new reuse way since Android 6 */
-    private NotificationCompat.Builder notificationBuilder;
-
-    /**
-     * Helper method to get an error message suitable to show to users for errors occurred in media playback,
-     *
-     * @param context   A context to access string resources.
-     * @param what      See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
-     * @param extra     See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
-     * @return Message suitable to users.
-     */
-    public static String getMessageForMediaError(Context context, int what, int extra) {
-        int messageId;
-
-        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);
-    }
-
-
-    /**
-     * Initialize a service instance
-     *
-     * {@inheritDoc}
-     */
-    @Override
-    public void onCreate() {
-        super.onCreate();
-        Log_OC.d(TAG, "Creating ownCloud media service");
-
-        wifiLock = ((WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE)).
-                createWifiLock(WifiManager.WIFI_MODE_FULL, MEDIA_WIFI_LOCK_TAG);
-
-        notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
-        notificationBuilder = new NotificationCompat.Builder(this);
-        notificationBuilder.setColor(ThemeUtils.primaryColor(this));
-        audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
-        binder = new MediaServiceBinder(this);
-    }
-
-
-    /**
-     * Entry point for Intents requesting actions, sent here via startService.
-     *
-     * {@inheritDoc}
-     */
-    @Override
-    public int onStartCommand(Intent intent, int flags, int startId) {
-        String action = intent.getAction();
-        if (ACTION_PLAY_FILE.equals(action)) {
-            processPlayFileRequest(intent);
-        } else if (ACTION_STOP_ALL.equals(action)) {
-            processStopRequest(true);
-        }
-
-        return START_NOT_STICKY; // don't want it to restart in case it's killed.
-    }
-
-
-    /**
-     * Processes a request to play a media file received as a parameter
-     *
-     * TODO If a new request is received when a file is being prepared, it is ignored. Is this what we want?
-     *
-     * @param intent    Intent received in the request with the data to identify the file to play.
-     */
-    private void processPlayFileRequest(Intent intent) {
-        if (state != State.PREPARING) {
-            currentFile = intent.getExtras().getParcelable(EXTRA_FILE);
-            account = intent.getExtras().getParcelable(EXTRA_ACCOUNT);
-            playOnPrepared = intent.getExtras().getBoolean(EXTRA_PLAY_ON_LOAD, false);
-            startPosition = intent.getExtras().getInt(EXTRA_START_POSITION, 0);
-            tryToGetAudioFocus();
-            playMedia();
-        }
-    }
-
-
-    /**
-     * Processes a request to play a media file.
-     */
-    protected void processPlayRequest() {
-        // request audio focus
-        tryToGetAudioFocus();
-
-        // actually play the song
-        if (state == State.STOPPED) {
-            // (re)start playback
-            playMedia();
-
-        } else if (state == State.PAUSED) {
-            // continue playback
-            state = State.PLAYING;
-            setUpAsForeground(String.format(getString(R.string.media_state_playing), currentFile.getFileName()));
-            configAndStartMediaPlayer();
-        }
-    }
-
-
-    /**
-     * Makes sure the media player exists and has been reset. This will create the media player
-     * if needed. reset the existing media player if one already exists.
-     */
-    protected void createMediaPlayerIfNeeded() {
-        if (player == null) {
-            player = new MediaPlayer();
-
-            // make sure the CPU won't go to sleep while media is playing
-            player.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
-
-            // the media player will notify the service when it's ready preparing, and when it's done playing
-            player.setOnPreparedListener(this);
-            player.setOnCompletionListener(this);
-            player.setOnErrorListener(this);
-
-        } else {
-            player.reset();
-        }
-    }
-
-    /**
-     * Processes a request to pause the current playback
-     */
-    protected void processPauseRequest() {
-        if (state == State.PLAYING) {
-            state = State.PAUSED;
-            player.pause();
-            releaseResources(false); // retain media player in pause
-            // TODO polite audio focus, instead of keep it owned; or not?
-        }
-    }
-
-
-    /**
-     * Processes a request to stop the playback.
-     *
-     * @param   force       When 'true', the playback is stopped no matter the value of state
-     */
-    protected void processStopRequest(boolean force) {
-        if (state != State.PREPARING || force) {
-            state = State.STOPPED;
-            currentFile = null;
-            account = null;
-            releaseResources(true);
-            giveUpAudioFocus();
-            stopSelf();     // service is no longer necessary
-        }
-    }
-
-
-    /**
-     * Releases resources used by the service for playback. This includes the "foreground service"
-     * status and notification, the wake locks and possibly the MediaPlayer.
-     *
-     * @param releaseMediaPlayer    Indicates whether the Media Player should also be released or not
-     */
-    protected void releaseResources(boolean releaseMediaPlayer) {
-        // stop being a foreground service
-        stopForeground(true);
-
-        // stop and release the Media Player, if it's available
-        if (releaseMediaPlayer && player != null) {
-            player.reset();
-            player.release();
-            player = null;
-        }
-
-        // release the Wifi lock, if holding it
-        if (wifiLock.isHeld()) {
-            wifiLock.release();
-        }
-    }
-
-    /**
-     * Fully releases the audio focus.
-     */
-    private void giveUpAudioFocus() {
-        if (audioFocus == AudioFocus.FOCUS
-                && audioManager != null
-                && AudioManager.AUDIOFOCUS_REQUEST_GRANTED == audioManager.abandonAudioFocus(this)) {
-
-            audioFocus = AudioFocus.NO_FOCUS;
-        }
-    }
-
-
-    /**
-     * Reconfigures MediaPlayer according to audio focus settings and starts/restarts it.
-     */
-    protected void configAndStartMediaPlayer() {
-        if (player == null) {
-            throw new IllegalStateException("player is NULL");
-        }
-
-        if (audioFocus == AudioFocus.NO_FOCUS) {
-            if (player.isPlaying()) {
-                player.pause();        // have to be polite; but state is not changed, to resume when focus is received again
-            }
-
-        } else {
-            if (audioFocus == AudioFocus.NO_FOCUS_CAN_DUCK) {
-                player.setVolume(DUCK_VOLUME, DUCK_VOLUME);
-
-            } else {
-                player.setVolume(1.0f, 1.0f); // full volume
-            }
-
-            if (!player.isPlaying()) {
-                player.start();
-            }
-        }
-    }
-
-
-    /**
-     * Requests the audio focus to the Audio Manager
-     */
-    private void tryToGetAudioFocus() {
-        if (audioFocus != AudioFocus.FOCUS
-                && audioManager != null
-                && AudioManager.AUDIOFOCUS_REQUEST_GRANTED == audioManager.requestAudioFocus(this,
-                                                                                             AudioManager.STREAM_MUSIC,
-                                                                                             AudioManager.AUDIOFOCUS_GAIN)
-                ) {
-            audioFocus = AudioFocus.FOCUS;
-        }
-    }
-
-
-    /**
-     * Starts playing the current media file.
-     */
-    protected void playMedia() {
-        state = State.STOPPED;
-        releaseResources(false); // release everything except MediaPlayer
-
-        try {
-            if (currentFile == null) {
-                Toast.makeText(this, R.string.media_err_nothing_to_play, Toast.LENGTH_LONG).show();
-                processStopRequest(true);
-                return;
-
-            } else if (account == null) {
-                Toast.makeText(this, R.string.media_err_not_in_owncloud, Toast.LENGTH_LONG).show();
-                processStopRequest(true);
-                return;
-            }
-
-            createMediaPlayerIfNeeded();
-            player.setAudioStreamType(AudioManager.STREAM_MUSIC);
-
-            if (currentFile.isDown()) {
-                player.setDataSource(currentFile.getStoragePath());
-                preparePlayer();
-            } else {
-                OwnCloudAccount ocAccount = new OwnCloudAccount(account, getBaseContext());
-                OwnCloudClient client = OwnCloudClientManagerFactory.getDefaultSingleton().
-                        getClientFor(ocAccount, getBaseContext());
-
-                new LoadStreamUrl(this, client).execute(currentFile.getLocalId());
-            }
-        } catch (AccountUtils.AccountNotFoundException | OperationCanceledException | AuthenticatorException e) {
-            Log_OC.e(TAG, "Loading stream url not possible: " + e.getMessage());
-        } catch (SecurityException | IOException | IllegalStateException | IllegalArgumentException e) {
-            Log_OC.e(TAG, e.getClass().getSimpleName() + " playing " + account.name + currentFile.getRemotePath(), e);
-            Toast.makeText(this, String.format(getString(R.string.media_err_playing), currentFile.getFileName()),
-                    Toast.LENGTH_LONG).show();
-            processStopRequest(true);
-        }
-    }
-
-    private void preparePlayer() {
-        state = State.PREPARING;
-        setUpAsForeground(String.format(getString(R.string.media_state_loading), currentFile.getFileName()));
-
-        // starts preparing the media player in background
-        player.prepareAsync();
-    }
-
-    /** Called when media player is done playing current song. */
-    public void onCompletion(MediaPlayer player) {
-        Toast.makeText(this, String.format(getString(R.string.media_event_done), currentFile.getFileName()), Toast.LENGTH_LONG).show();
-        if (mediaController != null) {
-            // somebody is still bound to the service
-            player.seekTo(0);
-            processPauseRequest();
-            mediaController.updatePausePlay();
-        } else {
-            // nobody is bound
-            processStopRequest(true);
-        }
-    }
-
-
-    /**
-     * Called when media player is done preparing.
-     *
-     * Time to start.
-     */
-    public void onPrepared(MediaPlayer player) {
-        state = State.PLAYING;
-        updateNotification(String.format(getString(R.string.media_state_playing), currentFile.getFileName()));
-        if (mediaController != null) {
-            mediaController.setEnabled(true);
-        }
-        player.seekTo(startPosition);
-        configAndStartMediaPlayer();
-        if (!playOnPrepared) {
-            processPauseRequest();
-        }
-
-        if (mediaController != null) {
-            mediaController.updatePausePlay();
-        }
-    }
-
-
-    /**
-     * Updates the status notification
-     */
-    private void updateNotification(String content) {
-        String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
-
-        // TODO check if updating the Intent is really necessary
-        Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
-        showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, currentFile);
-        showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, account);
-        showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-
-        notificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
-                                                                       (int) System.currentTimeMillis(),
-                                                                       showDetailsIntent,
-                                                                       PendingIntent.FLAG_UPDATE_CURRENT));
-        notificationBuilder.setWhen(System.currentTimeMillis());
-        notificationBuilder.setTicker(ticker);
-        notificationBuilder.setContentTitle(ticker);
-        notificationBuilder.setContentText(content);
-
-        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
-            notificationBuilder.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA);
-        }
-
-        notificationManager.notify(R.string.media_notif_ticker, notificationBuilder.build());
-    }
-
-
-    /**
-     * Configures the service as a foreground service.
-     *
-     * The system will avoid finishing the service as much as possible when resources as low.
-     *
-     * A notification must be created to keep the user aware of the existence of the service.
-     */
-    private void setUpAsForeground(String content) {
-        String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
-
-        /// creates status notification
-        // TODO put a progress bar to follow the playback progress
-        notificationBuilder.setSmallIcon(R.drawable.ic_play_arrow);
-        //mNotification.tickerText = text;
-        notificationBuilder.setWhen(System.currentTimeMillis());
-        notificationBuilder.setOngoing(true);
-
-        /// includes a pending intent in the notification showing the details view of the file
-        Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
-        showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, currentFile);
-        showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, account);
-        showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-        notificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
-                                                                       (int) System.currentTimeMillis(),
-                                                                       showDetailsIntent,
-                                                                       PendingIntent.FLAG_UPDATE_CURRENT));
-        notificationBuilder.setContentTitle(ticker);
-        notificationBuilder.setContentText(content);
-
-        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
-            notificationBuilder.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA);
-        }
-
-        startForeground(R.string.media_notif_ticker, notificationBuilder.build());
-    }
-
-    /**
-     * Called when there's an error playing media.
-     *
-     * Warns the user about the error and resets the media player.
-     */
-    public boolean onError(MediaPlayer mp, int what, int extra) {
-        Log_OC.e(TAG, "Error in audio playback, what = " + what + ", extra = " + extra);
-
-        String message = getMessageForMediaError(this, what, extra);
-        Toast.makeText(getApplicationContext(), message, Toast.LENGTH_SHORT).show();
-
-        processStopRequest(true);
-        return true;
-    }
-
-    /**
-     * Called by the system when another app tries to play some sound.
-     *
-     * {@inheritDoc}
-     */
-    @Override
-    public void onAudioFocusChange(int focusChange) {
-        if (focusChange > 0) {
-            // focus gain; check AudioManager.AUDIOFOCUS_* values
-            audioFocus = AudioFocus.FOCUS;
-            // restart media player with new focus settings
-            if (state == State.PLAYING) {
-                configAndStartMediaPlayer();
-            }
-
-        } else if (focusChange < 0) {
-            // focus loss; check AudioManager.AUDIOFOCUS_* values
-            boolean canDuck = AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK == focusChange;
-            audioFocus = canDuck ? AudioFocus.NO_FOCUS_CAN_DUCK : AudioFocus.NO_FOCUS;
-            // start/restart/pause media player with new focus settings
-            if (player != null && player.isPlaying()) {
-                configAndStartMediaPlayer();
-            }
-        }
-
-    }
-
-    /**
-     * Called when the service is finished for final clean-up.
-     *
-     * {@inheritDoc}
-     */
-    @Override
-    public void onDestroy() {
-        state = State.STOPPED;
-        releaseResources(true);
-        giveUpAudioFocus();
-        stopForeground(true);
-        super.onDestroy();
-    }
-
-
-    /**
-     * Provides a binder object that clients can use to perform operations on the MediaPlayer managed by the MediaService.
-     */
-    @Override
-    public IBinder onBind(Intent arg) {
-        return binder;
-    }
-
-
-    /**
-     * Called when ALL the bound clients were onbound.
-     *
-     * The service is destroyed if playback stopped or paused
-     */
-    @Override
-    public boolean onUnbind(Intent intent) {
-        if (state == State.PAUSED || state == State.STOPPED) {
-            processStopRequest(false);
-        }
-        return false;   // not accepting rebinding (default behaviour)
-    }
-
-    private static class LoadStreamUrl extends AsyncTask<String, Void, String> {
-
-        private OwnCloudClient client;
-        private WeakReference<MediaService> mediaServiceWeakReference;
-
-        public LoadStreamUrl(MediaService mediaService, OwnCloudClient client) {
-            this.client = client;
-            this.mediaServiceWeakReference = new WeakReference<>(mediaService);
-        }
-
-        @Override
-        protected String doInBackground(String... fileId) {
-            StreamMediaFileOperation sfo = new StreamMediaFileOperation(fileId[0]);
-            RemoteOperationResult result = sfo.execute(client);
-
-            if (!result.isSuccess()) {
-                return null;
-            }
-
-            return (String) result.getData().get(0);
-        }
-
-        @Override
-        protected void onPostExecute(String url) {
-            MediaService mediaService = mediaServiceWeakReference.get();
-
-            if (mediaService != null && mediaService.getCurrentFile() != null) {
-                if (url != null) {
-                    try {
-                        mediaService.player.setDataSource(url);
-
-                        // prevent the Wifi from going to sleep when streaming
-                        mediaService.wifiLock.acquire();
-                        mediaService.preparePlayer();
-                    } catch (IOException e) {
-                        Log_OC.e(TAG, "Streaming not possible: " + e.getMessage());
-                    }
-                } else {
-                    // we already show a toast with error from media player
-                    mediaService.processStopRequest(true);
-                }
-            }
-        }
-    }
-}

+ 0 - 181
src/main/java/com/owncloud/android/media/MediaServiceBinder.java

@@ -1,181 +0,0 @@
-/**
- * ownCloud Android client application
- *
- * @author David A. Velasco
- * Copyright (C) 2016 ownCloud Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.owncloud.android.media;
-
-
-import android.accounts.Account;
-import android.content.Intent;
-import android.media.MediaPlayer;
-import android.os.Binder;
-import android.widget.MediaController;
-
-import com.owncloud.android.datamodel.OCFile;
-import com.owncloud.android.lib.common.utils.Log_OC;
-import com.owncloud.android.media.MediaService.State;
-
-
-/**
- *  Binder allowing client components to perform operations on on the MediaPlayer managed by a MediaService instance.
- *
- *  Provides the operations of {@link MediaController.MediaPlayerControl}, and an extra method to check if
- *  an {@link OCFile} instance is handled by the MediaService.
- */
-public class MediaServiceBinder extends Binder implements MediaController.MediaPlayerControl {
-
-    private static final String TAG = MediaServiceBinder.class.getSimpleName();
-    /**
-     * {@link MediaService} instance to access with the binder
-     */
-    private MediaService mService;
-
-    /**
-     * Public constructor
-     *
-     * @param service       A {@link MediaService} instance to access with the binder 
-     */
-    public MediaServiceBinder(MediaService service) {
-        if (service == null) {
-            throw new IllegalArgumentException("Argument 'service' can not be null");
-        }
-        mService = service;
-    }
-
-    public boolean isPlaying(OCFile mFile) {
-        return mFile != null && mFile.equals(mService.getCurrentFile());
-    }
-
-    @Override
-    public boolean canPause() {
-        return true;
-    }
-
-    @Override
-    public boolean canSeekBackward() {
-        return true;
-    }
-
-    @Override
-    public boolean canSeekForward() {
-        return true;
-    }
-
-    @Override
-    public int getBufferPercentage() {
-        MediaPlayer currentPlayer = mService.getPlayer();
-        if (currentPlayer != null) {
-            return 100;
-            // TODO update for streamed playback; add OnBufferUpdateListener in MediaService
-        } else {
-            return 0;
-        }
-    }
-
-    @Override
-    public int getCurrentPosition() {
-        MediaPlayer currentPlayer = mService.getPlayer();
-        if (currentPlayer != null) {
-            return currentPlayer.getCurrentPosition();
-        } else {
-            return 0;
-        }
-    }
-
-    @Override
-    public int getDuration() {
-        MediaPlayer currentPlayer = mService.getPlayer();
-        if (currentPlayer != null) {
-            return currentPlayer.getDuration();
-        } else {
-            return 0;
-        }
-    }
-
-
-    /**
-     * Reports if the MediaService is playing a file or not.
-     *
-     * Considers that the file is being played when it is in preparation because the expected
-     * client of this method is a {@link MediaController} , and we do not want that the 'play'
-     * button is shown when the file is being prepared by the MediaService.
-     */
-    @Override
-    public boolean isPlaying() {
-        MediaService.State currentState = mService.getState();
-        return currentState == State.PLAYING || (currentState == State.PREPARING && mService.playOnPrepared);
-    }
-
-
-    @Override
-    public void pause() {
-        Log_OC.d(TAG, "Pausing through binder...");
-        mService.processPauseRequest();
-    }
-
-    @Override
-    public void seekTo(int pos) {
-        Log_OC.d(TAG, "Seeking " + pos + " through binder...");
-        MediaPlayer currentPlayer = mService.getPlayer();
-        MediaService.State currentState = mService.getState();
-        if (currentPlayer != null && currentState != State.PREPARING && currentState != State.STOPPED) {
-            currentPlayer.seekTo(pos);
-        }
-    }
-
-    @Override
-    public void start() {
-        Log_OC.d(TAG, "Starting through binder...");
-        mService.processPlayRequest();  // this will finish the service if there is no file preloaded to play
-    }
-
-    public void start(Account account, OCFile file, boolean playImmediately, int position) {
-        Log_OC.d(TAG, "Loading and starting through binder...");
-        Intent i = new Intent(mService, MediaService.class);
-        i.putExtra(MediaService.EXTRA_ACCOUNT, account);
-        i.putExtra(MediaService.EXTRA_FILE, file);
-        i.putExtra(MediaService.EXTRA_PLAY_ON_LOAD, playImmediately);
-        i.putExtra(MediaService.EXTRA_START_POSITION, position);
-        i.setAction(MediaService.ACTION_PLAY_FILE);
-        mService.startService(i);
-    }
-
-
-    public void registerMediaController(MediaControlView mediaController) {
-        mService.setMediaController(mediaController);
-    }
-
-    public void unregisterMediaController(MediaControlView mediaController) {
-        if (mediaController != null && mediaController == mService.getMediaController()) {
-            mService.setMediaController(null);
-        }
-
-    }
-
-    public boolean isInPlaybackState() {
-        MediaService.State currentState = mService.getState();
-        return currentState == MediaService.State.PLAYING || currentState == MediaService.State.PAUSED;
-    }
-
-    @Override
-    public int getAudioSessionId() {
-        return 1; // not really used
-    }
-}
-
-

+ 7 - 42
src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java

@@ -56,6 +56,7 @@ import android.view.ViewTreeObserver;
 import com.google.android.material.snackbar.Snackbar;
 import com.nextcloud.client.appinfo.AppInfo;
 import com.nextcloud.client.di.Injectable;
+import com.nextcloud.client.media.PlayerServiceConnection;
 import com.nextcloud.client.network.ConnectivityService;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.owncloud.android.MainApp;
@@ -77,8 +78,6 @@ import com.owncloud.android.lib.resources.files.SearchRemoteOperation;
 import com.owncloud.android.lib.resources.shares.OCShare;
 import com.owncloud.android.lib.resources.shares.ShareType;
 import com.owncloud.android.lib.resources.status.OwnCloudVersion;
-import com.owncloud.android.media.MediaService;
-import com.owncloud.android.media.MediaServiceBinder;
 import com.owncloud.android.operations.CopyFileOperation;
 import com.owncloud.android.operations.CreateFolderOperation;
 import com.owncloud.android.operations.CreateShareViaLinkOperation;
@@ -207,9 +206,6 @@ public class FileDisplayActivity extends FileActivity
 
     private Collection<MenuItem> mDrawerMenuItemstoShowHideList;
 
-    private MediaServiceBinder mMediaServiceBinder;
-    private MediaServiceConnection mMediaServiceConnection;
-
     public static final String KEY_IS_SEARCH_OPEN = "IS_SEARCH_OPEN";
     public static final String KEY_SEARCH_QUERY = "SEARCH_QUERY";
 
@@ -217,6 +213,7 @@ public class FileDisplayActivity extends FileActivity
     private boolean searchOpen;
 
     private SearchView searchView;
+    private PlayerServiceConnection mPlayerConnection;
 
     @Inject
     AppPreferences preferences;
@@ -284,6 +281,8 @@ public class FileDisplayActivity extends FileActivity
         if (Intent.ACTION_VIEW.equals(getIntent().getAction())) {
             handleOpenFileViaIntent(getIntent());
         }
+
+        mPlayerConnection = new PlayerServiceConnection(this);
     }
 
     @Override
@@ -1784,36 +1783,6 @@ public class FileDisplayActivity extends FileActivity
         }
     }
 
-    private MediaServiceConnection newMediaConnection(){
-        return new MediaServiceConnection();
-    }
-
-    /** Defines callbacks for service binding, passed to bindService() */
-    private class MediaServiceConnection implements ServiceConnection {
-
-        @Override
-        public void onServiceConnected(ComponentName component, IBinder service) {
-
-            if (component.equals(new ComponentName(FileDisplayActivity.this, MediaService.class))) {
-                Log_OC.d(TAG, "Media service connected");
-                mMediaServiceBinder = (MediaServiceBinder) service;
-
-            }else {
-                return;
-            }
-
-        }
-
-        @Override
-        public void onServiceDisconnected(ComponentName component) {
-            if (component.equals(new ComponentName(FileDisplayActivity.this,
-                    MediaService.class))) {
-                Log_OC.e(TAG, "Media service disconnected");
-                mMediaServiceBinder = null;
-            }
-        }
-    }
-
     /**
      * Updates the view associated to the activity after the finish of some operation over files
      * in the current account.
@@ -1945,14 +1914,10 @@ public class FileDisplayActivity extends FileActivity
         }
     }
 
-    public void setMediaServiceConnection() {
-        mMediaServiceConnection = newMediaConnection();// mediaServiceConnection;
-        bindService(new Intent(this, MediaService.class), mMediaServiceConnection, Context.BIND_AUTO_CREATE);
-    }
-
     private void tryStopPlaying(OCFile file) {
-        if (mMediaServiceConnection != null && MimeTypeUtil.isAudio(file) && mMediaServiceBinder.isPlaying(file)) {
-            mMediaServiceBinder.pause();
+        // placeholder for stop-on-delete future code
+        if(mPlayerConnection != null) {
+            mPlayerConnection.stop(file);
         }
     }
 

+ 32 - 179
src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java

@@ -2,7 +2,9 @@
  *   ownCloud Android client application
  *
  *   @author David A. Velasco
+ *   @author Chris Narkiewicz
  *   Copyright (C) 2016 ownCloud Inc.
+ *   Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
  *
  *   This program is free software: you can redistribute it and/or modify
  *   it under the terms of the GNU General Public License version 2,
@@ -21,10 +23,8 @@ package com.owncloud.android.ui.preview;
 
 import android.accounts.Account;
 import android.app.Activity;
-import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
-import android.content.ServiceConnection;
 import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
@@ -37,7 +37,6 @@ import android.media.MediaPlayer.OnPreparedListener;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Bundle;
-import android.os.IBinder;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuInflater;
@@ -51,11 +50,12 @@ import android.widget.LinearLayout;
 import android.widget.ProgressBar;
 import android.widget.RelativeLayout;
 import android.widget.TextView;
-import android.widget.Toast;
 import android.widget.VideoView;
 
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.di.Injectable;
+import com.nextcloud.client.media.ErrorFormat;
+import com.nextcloud.client.media.PlayerServiceConnection;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.OCFile;
 import com.owncloud.android.files.FileMenuFilter;
@@ -66,10 +66,7 @@ import com.owncloud.android.lib.common.OwnCloudClientManagerFactory;
 import com.owncloud.android.lib.common.operations.RemoteOperationResult;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.media.MediaControlView;
-import com.owncloud.android.media.MediaService;
-import com.owncloud.android.media.MediaServiceBinder;
 import com.owncloud.android.ui.activity.FileActivity;
-import com.owncloud.android.ui.activity.FileDisplayActivity;
 import com.owncloud.android.ui.dialog.ConfirmationDialogFragment;
 import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment;
 import com.owncloud.android.ui.fragment.FileFragment;
@@ -83,7 +80,6 @@ import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
 
-
 /**
  * This fragment shows a preview of a downloaded media file (audio or video).
  *
@@ -122,17 +118,14 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
     private ImageView mMultiListIcon;
     private ProgressBar mMultiListProgress;
 
-    private MediaServiceBinder mMediaServiceBinder;
     private MediaControlView mMediaController;
-    private MediaServiceConnection mMediaServiceConnection;
     private boolean mAutoplay;
-    private static boolean mOnResume;
     private boolean mPrepared;
+    private PlayerServiceConnection mMediaPlayerServiceConnection;
 
     private Uri mVideoUri;
     @Inject UserAccountManager accountManager;
 
-
     /**
      * Creates a fragment to preview a file.
      *
@@ -172,10 +165,6 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         mAutoplay = true;
     }
 
-
-    /**
-     * {@inheritDoc}
-     */
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -187,12 +176,9 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         mAccount = bundle.getParcelable(ACCOUNT);
         mSavedPlaybackPosition = bundle.getInt(PLAYBACK_POSITION);
         mAutoplay = bundle.getBoolean(AUTOPLAY);
+        mMediaPlayerServiceConnection = new PlayerServiceConnection(getContext());
     }
 
-
-    /**
-     * {@inheritDoc}
-     */
     @Override
     public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
         super.onCreateView(inflater, container, savedInstanceState);
@@ -214,8 +200,7 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         return view;
     }
 
-
-    protected void setupMultiView(View view) {
+    private void setupMultiView(View view) {
         mMultiListContainer = view.findViewById(R.id.empty_list_view);
         mMultiListMessage = view.findViewById(R.id.empty_list_view_text);
         mMultiListHeadline = view.findViewById(R.id.empty_list_view_headline);
@@ -233,7 +218,7 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         }
     }
 
-    public void setMessageForMultiList(String headline, @StringRes int message, @DrawableRes int icon) {
+    private void setMessageForMultiList(String headline, @StringRes int message, @DrawableRes int icon) {
         if (mMultiListContainer != null && mMultiListMessage != null) {
             mMultiListHeadline.setText(headline);
             mMultiListMessage.setText(message);
@@ -245,13 +230,8 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         }
     }
 
-
-    /**
-     * {@inheritDoc}
-     */
     @Override
     public void onActivityCreated(Bundle savedInstanceState) {
-        mOnResume = true;
         super.onActivityCreated(savedInstanceState);
         Log_OC.v(TAG, "onActivityCreated");
 
@@ -307,10 +287,6 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         }
     }
 
-
-    /**
-     * {@inheritDoc}
-     */
     @Override
     public void onSaveInstanceState(@NonNull Bundle outState) {
         super.onSaveInstanceState(outState);
@@ -326,25 +302,24 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
                 outState.putInt(PreviewMediaFragment.EXTRA_PLAY_POSITION, mSavedPlaybackPosition);
                 outState.putBoolean(PreviewMediaFragment.EXTRA_PLAYING, mAutoplay);
             }
-        }
-        else {
-            if (mMediaServiceBinder != null) {
-                outState.putInt(PreviewMediaFragment.EXTRA_PLAY_POSITION, mMediaServiceBinder.getCurrentPosition());
-                outState.putBoolean(PreviewMediaFragment.EXTRA_PLAYING, mMediaServiceBinder.isPlaying());
-            }
+        } else if(mMediaPlayerServiceConnection.isConnected()) {
+            outState.putInt(PreviewMediaFragment.EXTRA_PLAY_POSITION, mMediaPlayerServiceConnection.getCurrentPosition());
+            outState.putBoolean(PreviewMediaFragment.EXTRA_PLAYING, mMediaPlayerServiceConnection.isPlaying());
         }
     }
 
-
     @Override
     public void onStart() {
         super.onStart();
         Log_OC.v(TAG, "onStart");
-
         OCFile file = getFile();
         if (file != null) {
             if (MimeTypeUtil.isAudio(file)) {
-                bindMediaService();
+                mMediaController.setMediaPlayer(mMediaPlayerServiceConnection);
+                mMediaPlayerServiceConnection.bind();
+                mMediaPlayerServiceConnection.start(mAccount, file, mAutoplay, mSavedPlaybackPosition);
+                mMultiView.setVisibility(View.GONE);
+                mPreviewContainer.setVisibility(View.VISIBLE);
             } else if (MimeTypeUtil.isVideo(file)) {
                 stopAudio();
                 playVideo();
@@ -352,27 +327,16 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         }
     }
 
-
     private void stopAudio() {
-        Intent i = new Intent(getActivity(), MediaService.class);
-        i.setAction(MediaService.ACTION_STOP_ALL);
-        getActivity().startService(i);
+        mMediaPlayerServiceConnection.stop();
     }
 
-
-    /**
-     * {@inheritDoc}
-     */
     @Override
     public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
         super.onCreateOptionsMenu(menu, inflater);
         inflater.inflate(R.menu.file_actions_menu, menu);
     }
 
-
-    /**
-     * {@inheritDoc}
-     */
     @Override
     public void onPrepareOptionsMenu(Menu menu) {
         super.onPrepareOptionsMenu(menu);
@@ -443,13 +407,8 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
                 item.setEnabled(false);
             }
         }
-
     }
 
-
-    /**
-     * {@inheritDoc}
-     */
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
         switch (item.getItemId()) {
@@ -479,7 +438,6 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         }
     }
 
-
     /**
      * Update the file of the fragment with file value
      *
@@ -610,7 +568,6 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
             mMediaController.updatePausePlay();
         }
 
-
         /**
          * Called when an error in playback occurs.
          *
@@ -622,9 +579,9 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         public boolean onError(MediaPlayer mp, int what, int extra) {
             Log_OC.e(TAG, "Error in video playback, what = " + what + ", extra = " + extra);
             mPreviewContainer.setVisibility(View.GONE);
-            if (mVideoPreview.getWindowToken() != null) {
-                String message = MediaService.getMessageForMediaError(
-                        getActivity(), what, extra);
+            final Context context = getActivity();
+            if (mVideoPreview.getWindowToken() != null && context != null) {
+                String message = ErrorFormat.toString(context, what, extra);
                 mMultiView.setVisibility(View.VISIBLE);
                 setMessageForMultiList(message, R.string.preview_sorry, R.drawable.file_movie);
             }
@@ -633,7 +590,6 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
 
     }
 
-
     @Override
     public void onPause() {
         Log_OC.v(TAG, "onPause");
@@ -643,8 +599,6 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
     @Override
     public void onResume() {
         super.onResume();
-        mOnResume = !mOnResume;
-
         Log_OC.v(TAG, "onResume");
     }
 
@@ -657,19 +611,7 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
     @Override
     public void onStop() {
         Log_OC.v(TAG, "onStop");
-
-        mPrepared = false;
-        if (mMediaServiceConnection != null) {
-            Log_OC.d(TAG, "Unbinding from MediaService ...");
-            if (mMediaServiceBinder != null && mMediaController != null) {
-                mMediaController.stopMediaPlayerMessages();
-                mMediaServiceBinder.unregisterMediaController(mMediaController);
-            }
-            getActivity().unbindService(mMediaServiceConnection);
-            mMediaServiceConnection = null;
-            mMediaServiceBinder = null;
-        }
-
+        mMediaPlayerServiceConnection.unbind();
         super.onStop();
     }
 
@@ -707,103 +649,18 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         Log_OC.v(TAG, "onActivityResult " + this);
         super.onActivityResult(requestCode, resultCode, data);
         if (resultCode == Activity.RESULT_OK) {
-            mSavedPlaybackPosition = data.getExtras().getInt(
-                    PreviewVideoActivity.EXTRA_START_POSITION);
-            mAutoplay = data.getExtras().getBoolean(PreviewVideoActivity.EXTRA_AUTOPLAY);
+            mSavedPlaybackPosition = data.getIntExtra(PreviewVideoActivity.EXTRA_START_POSITION, 0);
+            mAutoplay = data.getBooleanExtra(PreviewVideoActivity.EXTRA_AUTOPLAY, false);
         }
     }
 
-
-    private void playAudio() {
-        OCFile file = getFile();
-        if (!mMediaServiceBinder.isPlaying(file) && !mOnResume) {
-            Log_OC.d(TAG, "starting playback of " + file.getStoragePath());
-            mMediaServiceBinder.start(mAccount, file, mAutoplay, mSavedPlaybackPosition);
-        }
-        else {
-            if (!mMediaServiceBinder.isPlaying() && mAutoplay) {
-                mMediaServiceBinder.start();
-                mMediaController.updatePausePlay();
-            }
-        }
-
-        mOnResume = false;
-    }
-
-
-    private void bindMediaService() {
-        Log_OC.d(TAG, "Binding to MediaService...");
-        if (mMediaServiceConnection == null) {
-            mMediaServiceConnection = new MediaServiceConnection();
-        }
-        getActivity().bindService(  new Intent(getActivity(),
-                                    MediaService.class),
-                                    mMediaServiceConnection,
-                                    Context.BIND_AUTO_CREATE);
-            // follow the flow in MediaServiceConnection#onServiceConnected(...)
-
-        ((FileDisplayActivity) getActivity()).setMediaServiceConnection();
-    }
-
-    /** Defines callbacks for service binding, passed to bindService() */
-    private class MediaServiceConnection implements ServiceConnection {
-
-        @Override
-        public void onServiceConnected(ComponentName component, IBinder service) {
-            if (getActivity() != null
-                    && component.equals(new ComponentName(getActivity(), MediaService.class))) {
-                Log_OC.d(TAG, "Media service connected");
-                mMediaServiceBinder = (MediaServiceBinder) service;
-                if (mMediaServiceBinder != null) {
-                    prepareMediaController();
-                    playAudio();    // do not wait for the touch of nobody to play audio
-
-                    Log_OC.d(TAG, "Successfully bound to MediaService, MediaController ready");
-
-                } else {
-                    Log_OC.e(TAG, "Unexpected response from MediaService while binding");
-                }
-            }
-        }
-
-        private void prepareMediaController() {
-            mMultiView.setVisibility(View.GONE);
-            mPreviewContainer.setVisibility(View.VISIBLE);
-            mMediaServiceBinder.registerMediaController(mMediaController);
-            if (mMediaController != null) {
-                mMediaController.setMediaPlayer(mMediaServiceBinder);
-                mMediaController.setEnabled(true);
-                mMediaController.updatePausePlay();
-            }
-        }
-
-        @Override
-        public void onServiceDisconnected(ComponentName component) {
-            if (component.equals(new ComponentName(getActivity(), MediaService.class))) {
-                Log_OC.w(TAG, "Media service suddenly disconnected");
-                if (mMediaController != null) {
-                    mMediaController.setMediaPlayer(null);
-                }
-                else {
-                    Toast.makeText(
-                            getActivity(),
-                            "No media controller to release when disconnected from media service",
-                            Toast.LENGTH_SHORT).show();
-                }
-                mMediaServiceBinder = null;
-                mMediaServiceConnection = null;
-            }
-        }
-    }
-
-
     /**
      * Opens the previewed file with an external application.
      */
     private void openFile() {
         stopPreview(true);
         containerActivity.getFileOperationsHelper().openFile(getFile());
-        finish();
+        finishPreview();
     }
 
     /**
@@ -817,29 +674,25 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         return file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file));
     }
 
-
     public void stopPreview(boolean stopAudio) {
         OCFile file = getFile();
         if (MimeTypeUtil.isAudio(file) && stopAudio) {
-            mMediaServiceBinder.pause();
-
-        }
-        else {
-            if (MimeTypeUtil.isVideo(file)) {
-                mVideoPreview.stopPlayback();
-            }
+            mMediaPlayerServiceConnection.pause();
+        } else if (MimeTypeUtil.isVideo(file)) {
+            mVideoPreview.stopPlayback();
         }
     }
 
-
     /**
      * Finishes the preview
      */
-    private void finish() {
-        getActivity().onBackPressed();
+    private void finishPreview() {
+        final Activity activity = getActivity();
+        if (activity != null) {
+            activity.onBackPressed();
+        }
     }
 
-
     public int getPosition() {
         if (mPrepared) {
             mSavedPlaybackPosition = mVideoPreview.getCurrentPosition();

+ 2 - 2
src/main/java/com/owncloud/android/ui/preview/PreviewVideoActivity.java

@@ -32,10 +32,10 @@ import android.os.Bundle;
 import android.widget.MediaController;
 import android.widget.VideoView;
 
+import com.nextcloud.client.media.ErrorFormat;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.OCFile;
 import com.owncloud.android.lib.common.utils.Log_OC;
-import com.owncloud.android.media.MediaService;
 import com.owncloud.android.ui.activity.FileActivity;
 import com.owncloud.android.utils.MimeTypeUtil;
 
@@ -180,7 +180,7 @@ public class PreviewVideoActivity extends FileActivity implements OnCompletionLi
         }
 
         if (mVideoPlayer.getWindowToken() != null) {
-            String message = MediaService.getMessageForMediaError(this, what, extra);
+            String message = ErrorFormat.toString(this, what, extra);
             new AlertDialog.Builder(this)
                     .setMessage(message)
                     .setPositiveButton(android.R.string.VideoView_error_button,

+ 0 - 5
src/main/res/values/strings.xml

@@ -216,17 +216,12 @@
 
     <string name="media_notif_ticker">%1$s music player</string>
     <string name="media_state_playing">%1$s (playing)</string>
-    <string name="media_state_loading">%1$s (loading)</string>
-    <string name="media_event_done">%1$s playback finished</string>
-    <string name="media_err_nothing_to_play">No media file found</string>
-    <string name="media_err_not_in_owncloud">The file is not in a valid account</string>
     <string name="media_err_unsupported">Unsupported media codec</string>
     <string name="media_err_io">Could not read the media file</string>
     <string name="media_err_malformed">The media file has incorrect encoding</string>
     <string name="media_err_timeout">Attempt to play file timed out</string>
     <string name="media_err_invalid_progressive_playback">The media file can not be streamed</string>
     <string name="media_err_unknown">The built-in media player is unable to play the media file</string>
-    <string name="media_err_playing">Unexpected error while trying to play %1$s</string>
     <string name="media_rewind_description">Rewind button</string>
     <string name="media_play_pause_description">Play or pause button</string>
     <string name="media_forward_description">Fast forward button</string>

+ 73 - 0
src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt

@@ -0,0 +1,73 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.argThat
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Before
+import org.junit.Test
+import org.mockito.ArgumentMatcher
+
+class AudioFocusManagerTest {
+
+    private val audioManager = mock<AudioManager>()
+    private val callback = mock<(AudioFocus)->Unit>()
+    private lateinit var audioFocusManager: AudioFocusManager
+
+    val audioRequestMatcher = object : ArgumentMatcher<AudioFocusRequest> {
+        override fun matches(argument: AudioFocusRequest?): Boolean = true
+    }
+
+    @Before
+    fun setUp() {
+        audioFocusManager = AudioFocusManager(audioManager, callback)
+        whenever(audioManager.requestAudioFocus(any(), any(), any()))
+            .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
+        whenever(audioManager.abandonAudioFocusRequest(argThat(audioRequestMatcher)))
+            .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
+        whenever(audioManager.abandonAudioFocusRequest(any()))
+            .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
+    }
+
+    @Test
+    fun `acquiring focus triggers callback immediately`() {
+        audioFocusManager.requestFocus()
+        verify(callback).invoke(AudioFocus.FOCUS)
+    }
+
+    @Test
+    fun `failing to acquire focus triggers callback immediately`() {
+        whenever(audioManager.requestAudioFocus(any(), any(), any()))
+            .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_FAILED)
+        audioFocusManager.requestFocus()
+        verify(callback).invoke(AudioFocus.LOST)
+    }
+
+    @Test
+    fun `releasing focus triggers callback immediately`() {
+        audioFocusManager.releaseFocus()
+        verify(callback).invoke(AudioFocus.LOST)
+    }
+}

+ 50 - 0
src/test/java/com/nextcloud/client/media/AudioFocusTest.kt

@@ -0,0 +1,50 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import android.media.AudioManager
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class AudioFocusTest {
+
+    @Test
+    fun `invalid values result in null`() {
+        val focus = AudioFocus.fromPlatformFocus(-10000)
+        assertNull(focus)
+    }
+
+    @Test
+    fun `audio focus values are converted`() {
+        val validValues = listOf(
+            AudioManager.AUDIOFOCUS_GAIN,
+            AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
+            AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
+            AudioManager.AUDIOFOCUS_LOSS,
+            AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
+            AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
+        )
+        validValues.forEach {
+            val focus = AudioFocus.fromPlatformFocus(-it)
+            assertNotNull(focus)
+        }
+    }
+}

+ 683 - 0
src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt

@@ -0,0 +1,683 @@
+/**
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.media
+
+import com.nextcloud.client.media.PlayerStateMachine.Event
+import com.nextcloud.client.media.PlayerStateMachine.State
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.inOrder
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Suite
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+    PlayerStateMachineTest.Constructor::class,
+    PlayerStateMachineTest.EventHandling::class,
+    PlayerStateMachineTest.Stopped::class,
+    PlayerStateMachineTest.Downloading::class,
+    PlayerStateMachineTest.Preparing::class,
+    PlayerStateMachineTest.AwaitFocus::class,
+    PlayerStateMachineTest.Focused::class,
+    PlayerStateMachineTest.Ducked::class,
+    PlayerStateMachineTest.Paused::class
+)
+internal class PlayerStateMachineTest {
+
+    abstract class Base {
+        @Mock
+        protected lateinit var delegate: PlayerStateMachine.Delegate
+        protected lateinit var fsm: PlayerStateMachine
+
+        fun setUp(initialState: State) {
+            MockitoAnnotations.initMocks(this)
+            fsm = PlayerStateMachine(initialState, delegate)
+        }
+    }
+
+    class Constructor {
+
+        private val delegate: PlayerStateMachine.Delegate = mock()
+
+        @Test
+        fun `default state is stopped`() {
+            val fsm = PlayerStateMachine(delegate)
+            assertEquals(State.STOPPED, fsm.state)
+        }
+
+        @Test
+        fun `inital state can be set`() {
+            val fsm = PlayerStateMachine(State.PREPARING, delegate)
+            assertEquals(State.PREPARING, fsm.state)
+        }
+    }
+
+    class EventHandling : Base() {
+
+        @Before
+        fun setUp() {
+            super.setUp(State.STOPPED)
+        }
+
+        @Test
+        fun `can post multiple events from callback`() {
+            whenever(delegate.isDownloaded).thenReturn(false)
+            whenever(delegate.isAutoplayEnabled).thenReturn(false)
+            whenever(delegate.hasEnqueuedFile).thenReturn(true)
+            whenever(delegate.onStartDownloading()).thenAnswer {
+                fsm.post(Event.DOWNLOADED)
+                fsm.post(Event.PREPARED)
+            }
+
+            // WHEN
+            //      an event is posted from a state machine callback
+            fsm.post(Event.PLAY) // posts error() in callback
+
+            // THEN
+            //      enqueued events is handled triggering transitions
+            assertEquals(State.PAUSED, fsm.state)
+            verify(delegate).onStartRunning()
+            verify(delegate).onStartDownloading()
+            verify(delegate).onPrepare()
+            verify(delegate).onPausePlayback()
+        }
+
+        @Test
+        fun `unhandled events are ignored`() {
+            // GIVEN
+            //      state machine is in STOPPED state
+            //      PAUSE event is not handled in this staet
+
+            // WHEN
+            //      state machine receives unhandled PAUSE event
+            fsm.post(Event.PAUSE)
+
+            // THEN
+            //      event is ignored
+            //      exception is not thrown
+        }
+    }
+
+    class Stopped : Base() {
+
+        @Before
+        fun setUp() {
+            super.setUp(State.STOPPED)
+        }
+
+        @Test
+        fun `initiall state is stopped`() {
+            assertEquals(State.STOPPED, fsm.state)
+        }
+
+        @Test
+        fun `playing requires enqueued file`() {
+            // GIVEN
+            //      no file is enqueued
+            whenever(delegate.hasEnqueuedFile).thenReturn(false)
+
+            // WHEN
+            //      play is triggered
+            fsm.post(Event.PLAY)
+
+            // THEN
+            //      remains in stopped state
+            assertEquals(State.STOPPED, fsm.state)
+        }
+
+        @Test
+        fun `playing remote media triggers downloading`() {
+            // GIVEN
+            //      file is enqueued
+            //      media is not downloaded
+            whenever(delegate.hasEnqueuedFile).thenReturn(true)
+            whenever(delegate.isDownloaded).thenReturn(false)
+
+            // WHEN
+            //      play is requested
+            fsm.post(Event.PLAY)
+
+            // THEN
+            //      enqueued file is loaded
+            //      media stream download starts
+            assertEquals(State.DOWNLOADING, fsm.state)
+            verify(delegate).onStartRunning()
+            verify(delegate).onStartDownloading()
+        }
+
+        @Test
+        fun `playing local media triggers player preparation`() {
+            // GIVEN
+            //      file is enqueued
+            //      media is downloaded
+            whenever(delegate.hasEnqueuedFile).thenReturn(true)
+            whenever(delegate.isDownloaded).thenReturn(true)
+
+            // WHEN
+            //      play is requested
+            fsm.post(Event.PLAY)
+
+            // THEN
+            //      player preparation starts
+            assertEquals(State.PREPARING, fsm.state)
+            verify(delegate).onPrepare()
+        }
+    }
+
+    class Downloading : Base() {
+
+        // GIVEN
+        //      player is downloading stream URL
+        @Before
+        fun setUp() {
+            setUp(State.DOWNLOADING)
+        }
+
+        @Test
+        fun `stream url download is successfull`() {
+            // WHEN
+            //      stream url downloaded
+            fsm.post(Event.DOWNLOADED)
+
+            // THEN
+            //      player is preparing
+            assertEquals(State.PREPARING, fsm.state)
+            verify(delegate).onPrepare()
+        }
+
+        @Test
+        fun `stream url download failed`() {
+            // WHEN
+            //      download error
+            fsm.post(Event.ERROR)
+
+            // THEN
+            //      player is stopped
+            assertEquals(State.STOPPED, fsm.state)
+            verify(delegate).onError()
+        }
+
+        @Test
+        fun `player stopped`() {
+            // WHEN
+            //      download error
+            fsm.post(Event.STOP)
+
+            // THEN
+            //      player is stopped
+            assertEquals(State.STOPPED, fsm.state)
+            verify(delegate).onStopped()
+        }
+
+        @Test
+        fun `player error`() {
+            // WHEN
+            //      player error
+            fsm.post(Event.ERROR)
+
+            // THEN
+            //      player is stopped
+            //      error handler is called
+            assertEquals(State.STOPPED, fsm.state)
+            verify(delegate).onError()
+        }
+    }
+
+    class Preparing : Base() {
+
+        @Before
+        fun setUp() {
+            setUp(State.PREPARING)
+        }
+
+        @Test
+        fun `start in autoplay mode`() {
+            // GIVEN
+            //      media player is preparing
+            //      autoplay is enabled
+            whenever(delegate.isAutoplayEnabled).thenReturn(true)
+
+            // WHEN
+            //      media player is ready
+            fsm.post(Event.PREPARED)
+
+            // THEN
+            //      start playing
+            //      request audio focus
+            //      awaiting focus
+            assertEquals(State.AWAIT_FOCUS, fsm.state)
+            verify(delegate).onRequestFocus()
+        }
+
+        @Test
+        fun `start in paused mode`() {
+            // GIVEN
+            //      media player is preparing
+            //      autoplay is disabled
+            whenever(delegate.isAutoplayEnabled).thenReturn(false)
+
+            // WHEN
+            //      media player is ready
+            fsm.post(Event.PREPARED)
+
+            // THEN
+            //      media player is not started
+            assertEquals(State.PAUSED, fsm.state)
+            verify(delegate, never()).onStartPlayback()
+        }
+
+        @Test
+        fun `player is stopped during preparation`() {
+            // GIVEN
+            //      media player is preparing
+            // WHEN
+            //      stopped
+            fsm.post(Event.STOP)
+
+            // THEN
+            //      player is stopped
+            assertEquals(State.STOPPED, fsm.state)
+            verify(delegate).onStopped()
+        }
+
+        @Test
+        fun `error during preparation`() {
+            // GIVEN
+            //      media player is preparing
+            // WHEN
+            //      download error
+            fsm.post(Event.ERROR)
+
+            // THEN
+            //      player is stopped
+            //      error callback is invoked
+            assertEquals(State.STOPPED, fsm.state)
+            verify(delegate).onError()
+        }
+    }
+
+    class AwaitFocus : Base() {
+
+        @Before
+        fun setUp() {
+            setUp(State.AWAIT_FOCUS)
+        }
+
+        @Test
+        fun pause() {
+            // GIVEN
+            //      media player is awaiting focus
+            // WHEN
+            //      media player is paused
+            fsm.post(Event.PAUSE)
+
+            // THEN
+            //      media player enters paused state
+            //      focus is released
+            assertEquals(State.PAUSED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onPausePlayback()
+            }
+        }
+
+        @Test
+        fun `audio focus denied`() {
+            // GIVEN
+            //      media player is awaiting focus
+            // WHEN
+            //      audio focus was denied
+            fsm.post(Event.FOCUS_LOST)
+
+            // THEN
+            //      media player enters paused state
+            assertEquals(State.PAUSED, fsm.state)
+            verify(delegate).onPausePlayback()
+        }
+
+        @Test
+        fun `audio focus granted`() {
+            // GIVEN
+            //      media player is awaiting focus
+            // WHEN
+            //      audio focus was granted
+            fsm.post(Event.FOCUS_GAIN)
+
+            // THEN
+            //      media player enters focused state
+            //      playback is started
+            assertEquals(State.FOCUSED, fsm.state)
+            verify(delegate).onStartPlayback()
+        }
+
+        @Test
+        fun stop() {
+            // GIVEN
+            //      media player is awaiting focus
+            // WHEN
+            //      stopped
+            fsm.post(Event.STOP)
+
+            // THEN
+            //      player is stopped
+            //      focus is released
+            assertEquals(State.STOPPED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onStopped()
+            }
+        }
+
+        @Test
+        fun error() {
+            // GIVEN
+            //      media player is playing
+            // WHEN
+            //      error
+            fsm.post(Event.ERROR)
+
+            // THEN
+            //      player is stopped
+            //      focus is released
+            assertEquals(State.STOPPED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onError()
+            }
+        }
+    }
+
+    class Focused : Base() {
+
+        @Before
+        fun setUp() {
+            setUp(State.FOCUSED)
+        }
+
+        @Test
+        fun pause() {
+            // GIVEN
+            //      media player is awaiting focus
+            // WHEN
+            //      media player is paused
+            fsm.post(Event.PAUSE)
+
+            // THEN
+            //      media player enters paused state
+            //      focus is released
+            assertEquals(State.PAUSED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onPausePlayback()
+            }
+        }
+
+        @Test
+        fun `lost focus`() {
+            // GIVEN
+            //      media player is awaiting focus
+            // WHEN
+            //      media player lost audio focus
+            fsm.post(Event.FOCUS_LOST)
+
+            // THEN
+            //      media player enters paused state
+            //      focus is released
+            assertEquals(State.PAUSED, fsm.state)
+            verify(delegate).onPausePlayback()
+        }
+
+        @Test
+        fun `audio focus duck`() {
+            // GIVEN
+            //      media player is playing
+            // WHEN
+            //      media player focus duck is requested
+            fsm.post(Event.FOCUS_DUCK)
+
+            // THEN
+            //      media player ducks
+            assertEquals(State.DUCKED, fsm.state)
+            verify(delegate).onAudioDuck(eq(true))
+        }
+
+        @Test
+        fun stop() {
+            // GIVEN
+            //      media player is awaiting focus
+            // WHEN
+            //      stopped
+            fsm.post(Event.STOP)
+
+            // THEN
+            //      player is stopped
+            //      focus is released
+            assertEquals(State.STOPPED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onStopped()
+            }
+        }
+
+        @Test
+        fun error() {
+            // GIVEN
+            //      media player is playing
+            // WHEN
+            //      error
+            fsm.post(Event.ERROR)
+
+            // THEN
+            //      player is stopped
+            //      focus is released
+            //      error is signaled
+            assertEquals(State.STOPPED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onError()
+            }
+        }
+    }
+
+    class Ducked : Base() {
+
+        @Before
+        fun setUp() {
+            setUp(State.DUCKED)
+        }
+
+        @Test
+        fun pause() {
+            // GIVEN
+            //      media player is playing
+            //      audio focus is ducked
+            // WHEN
+            //      media player is paused
+            fsm.post(Event.PAUSE)
+
+            // THEN
+            //      audio focus duck is disabled
+            //      focus is released
+            //      playback is paused
+            assertEquals(State.PAUSED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onAudioDuck(eq(false))
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onPausePlayback()
+            }
+        }
+
+        @Test
+        fun `lost focus`() {
+            // GIVEN
+            //      media player is playing
+            //      audio focus is ducked
+            // WHEN
+            //      media player is looses focus
+            fsm.post(Event.FOCUS_LOST)
+
+            // THEN
+            //      audio focus duck is disabled
+            //      focus is released
+            //      playback is paused
+            assertEquals(State.PAUSED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onAudioDuck(eq(false))
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onPausePlayback()
+            }
+            // WHEN
+            //      media player is paused
+            fsm.post(Event.PAUSE)
+
+            // THEN
+            //      audio focus duck is disabled
+            //      focus is released
+            //      playback is paused
+            assertEquals(State.PAUSED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onAudioDuck(eq(false))
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onPausePlayback()
+            }
+        }
+
+        @Test
+        fun `audio focus is re-gained`() {
+            // GIVEN
+            //      media player is playing
+            //      audio focus is ducked
+            // WHEN
+            //      media player focus duck is requested
+            fsm.post(Event.FOCUS_GAIN)
+
+            // THEN
+            //      media player is focused
+            //      audio focus duck is disabled
+            //      playback is not restarted
+            assertEquals(State.FOCUSED, fsm.state)
+            verify(delegate).onAudioDuck(eq(false))
+            verify(delegate, never()).onStartPlayback()
+        }
+
+        @Test
+        fun stop() {
+            // GIVEN
+            //      media player is playing
+            //      audio focus is ducked
+            // WHEN
+            //      media player is stopped
+            fsm.post(Event.STOP)
+
+            // THEN
+            //      audio focus duck is disabled
+            //      focus is released
+            //      playback is stopped
+            assertEquals(State.STOPPED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onAudioDuck(eq(false))
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onStopped()
+            }
+        }
+
+        @Test
+        fun error() {
+            // GIVEN
+            //      media player is playing
+            //      audio focus is ducked
+            // WHEN
+            //      error
+            fsm.post(Event.ERROR)
+
+            // THEN
+            //      audio focus duck is disabled
+            //      focus is released
+            //      playback is stopped
+            //      error is signaled
+            assertEquals(State.STOPPED, fsm.state)
+            inOrder(delegate).run {
+                verify(delegate).onAudioDuck(eq(false))
+                verify(delegate).onReleaseFocus()
+                verify(delegate).onError()
+            }
+        }
+    }
+
+    class Paused : Base() {
+
+        @Before
+        fun setUp() {
+            setUp(State.PAUSED)
+        }
+
+        @Test
+        fun pause() {
+            // GIVEN
+            //      media player is paused
+            // WHEN
+            //      media player is resumed
+            fsm.post(Event.PLAY)
+
+            // THEN
+            //      media player enters playing state
+            //      audio focus is requsted
+            assertEquals(State.AWAIT_FOCUS, fsm.state)
+            verify(delegate).onRequestFocus()
+        }
+
+        @Test
+        fun stop() {
+            // GIVEN
+            //      media player is playing
+            // WHEN
+            //      stopped
+            fsm.post(Event.STOP)
+
+            // THEN
+            //      player is stopped
+            assertEquals(State.STOPPED, fsm.state)
+            verify(delegate).onStopped()
+        }
+
+        @Test
+        fun error() {
+            // GIVEN
+            //      media player is playing
+            // WHEN
+            //      error
+            fsm.post(Event.ERROR)
+
+            // THEN
+            //      player is stopped
+            //      error callback is invoked
+            assertEquals(State.STOPPED, fsm.state)
+            verify(delegate).onError()
+        }
+    }
+}