mirror of
https://github.com/nextcloud/android.git
synced 2024-11-23 05:35:39 +03:00
Merge pull request #4208 from nextcloud/ezaquarii/new-media-player-service
New audio media player service
This commit is contained in:
commit
0f975647b6
25 changed files with 2089 additions and 1220 deletions
|
@ -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 @@
|
|||
429
|
||||
427
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
src/main/java/com/nextcloud/client/media/AudioFocus.kt
Normal file
45
src/main/java/com/nextcloud/client/media/AudioFocus.kt
Normal file
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
src/main/java/com/nextcloud/client/media/ErrorFormat.kt
Normal file
94
src/main/java/com/nextcloud/client/media/ErrorFormat.kt
Normal file
|
@ -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
src/main/java/com/nextcloud/client/media/LoadUrlTask.kt
Normal file
49
src/main/java/com/nextcloud/client/media/LoadUrlTask.kt
Normal file
|
@ -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
src/main/java/com/nextcloud/client/media/Player.kt
Normal file
310
src/main/java/com/nextcloud/client/media/Player.kt
Normal file
|
@ -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
src/main/java/com/nextcloud/client/media/PlayerError.kt
Normal file
3
src/main/java/com/nextcloud/client/media/PlayerError.kt
Normal file
|
@ -0,0 +1,3 @@
|
|||
package com.nextcloud.client.media
|
||||
|
||||
data class PlayerError(val message: String)
|
148
src/main/java/com/nextcloud/client/media/PlayerService.kt
Normal file
148
src/main/java/com/nextcloud/client/media/PlayerService.kt
Normal file
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt
Normal file
229
src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt
Normal file
|
@ -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
src/main/java/com/nextcloud/client/media/PlaylistItem.kt
Normal file
6
src/main/java/com/nextcloud/client/media/PlaylistItem.kt
Normal file
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
src/test/java/com/nextcloud/client/media/AudioFocusTest.kt
Normal file
50
src/test/java/com/nextcloud/client/media/AudioFocusTest.kt
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue