Merge pull request #4208 from nextcloud/ezaquarii/new-media-player-service

New audio media player service
This commit is contained in:
Tobias Kaminsky 2019-10-01 07:45:36 +02:00 committed by GitHub
commit 0f975647b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 2089 additions and 1220 deletions

View file

@ -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 {

View file

@ -1 +1 @@
429
427

View file

@ -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"

View file

@ -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);
}
}

View file

@ -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();
}

View 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
}
}
}

View file

@ -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)
}
}

View 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"
}
}

View 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)
}
}
}

View 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
}

View file

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

View 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())
}
}

View file

@ -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
}

View 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
}
}
}

View 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)

View file

@ -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);
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

View file

@ -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);
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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);
}
}

View 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,28 +674,24 @@ 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)) {
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) {

View file

@ -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,

View file

@ -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>

View file

@ -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)
}
}

View 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)
}
}
}

View file

@ -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()
}
}
}