Skip to content

Commit

Permalink
Some cleanup and enhancements to the Native Audio implementation (#75)
Browse files Browse the repository at this point in the history
* Drops MediaSessionConnector & Subclasses PlayerNotificationManager for improved controls

* Removes unnecessary exo player packages

* Refactors Podcast bridge interface to handle JSON encoded messages & bug fixes
  • Loading branch information
fdocr committed May 27, 2020
1 parent 5fa0485 commit 651f9c7
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 91 deletions.
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ dependencies {
implementation(Libs.kotlin_stdlib_jdk8)
implementation(Libs.browser)

implementation("com.google.android.exoplayer:exoplayer:2.11.4")
implementation("com.google.android.exoplayer:exoplayer-core:2.11.4")
implementation("com.google.android.exoplayer:exoplayer-ui:2.11.4")
implementation("com.google.android.exoplayer:extension-mediasession:2.11.4")
implementation("com.google.firebase:firebase-messaging:18.0.0")
implementation("com.pusher:push-notifications-android:1.6.2")
implementation("com.google.code.gson:gson:2.8.6")

testImplementation(Libs.junit)
androidTestImplementation(Libs.androidx_test_runner)
Expand Down
112 changes: 57 additions & 55 deletions app/src/main/java/to/dev/dev_android/media/AudioService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,16 @@ import android.graphics.Bitmap
import android.media.MediaMetadata
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import androidx.annotation.MainThread
import androidx.annotation.Nullable
import androidx.lifecycle.LifecycleService
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
Expand All @@ -39,7 +35,6 @@ class AudioService : LifecycleService() {
private var player: SimpleExoPlayer? = null
private var playerNotificationManager: PlayerNotificationManager? = null
private var mediaSession: MediaSessionCompat? = null
private var mediaSessionConnector: MediaSessionConnector? = null

inner class AudioServiceBinder : Binder() {
val service: AudioService
Expand All @@ -48,7 +43,10 @@ class AudioService : LifecycleService() {

companion object {
@MainThread
fun newIntent(context: Context, episodeUrl: String) = Intent(context, AudioService::class.java).apply {
fun newIntent(
context: Context,
episodeUrl: String
) = Intent(context, AudioService::class.java).apply {
putExtra(argPodcastUrl, episodeUrl)
}

Expand Down Expand Up @@ -80,7 +78,7 @@ class AudioService : LifecycleService() {
.setContentType(C.CONTENT_TYPE_SPEECH)
.build()

playerNotificationManager = PlayerNotificationManager.createWithNotificationChannel(
playerNotificationManager = PodcastPlayerNotificationManager.createWithNotificationChannel(
applicationContext,
playbackChannelId,
R.string.app_name,
Expand All @@ -100,7 +98,10 @@ class AudioService : LifecycleService() {
}

@Nullable
override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? {
override fun getCurrentLargeIcon(
player: Player,
callback: PlayerNotificationManager.BitmapCallback
): Bitmap? {
return null
}
},
Expand Down Expand Up @@ -131,52 +132,35 @@ class AudioService : LifecycleService() {
}
}
).apply {
// Omit skip previous and next actions.
setUseNavigationActions(false)

// Add stop action.
setUseStopAction(true)

setUseNavigationActionsInCompactView(false)

setUseNavigationActionsInCompactView(true)
setFastForwardIncrementMs(incrementMs.toLong())
setRewindIncrementMs(incrementMs.toLong())

setPlayer(player)
invalidate()
}

// Show lock screen controls and let apps like Google assistant manager playback.
mediaSession = MediaSessionCompat(this, mediaSessionTag).apply {
isActive = true
mediaSession = MediaSessionCompat(this, mediaSessionTag)
val builder = MediaMetadataCompat.Builder()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
builder.putString(MediaMetadata.METADATA_KEY_TITLE, episodeName)
.putString(MediaMetadata.METADATA_KEY_ARTIST, podcastName)
}
val metadata = MediaMetadataCompat.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, episodeName)
.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, podcastName)
.build()
mediaSession?.setMetadata(metadata)

mediaSession?.setMetadata(builder.build())
playerNotificationManager?.setMediaSessionToken(mediaSession!!.sessionToken)
mediaSessionConnector = MediaSessionConnector(mediaSession!!).apply {
setQueueNavigator(object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
val title = episodeName ?: getString(R.string.app_name)
val description = podcastName ?: getString(R.string.playback_channel_description)

return MediaDescriptionCompat.Builder()
.setTitle(title)
.setDescription(description)
.build()
}
})

setFastForwardIncrementMs(incrementMs)
setRewindIncrementMs(incrementMs)
setPlayer(player)
}
}

@MainThread
fun play() {
fun play(audioUrl: String?, seconds: String?) {
if (currentPodcastUrl != audioUrl) {
currentPodcastUrl = audioUrl
preparePlayer()
seekTo("0")
} else {
seekTo(seconds)
}
player?.playWhenReady = true
}

Expand All @@ -186,26 +170,39 @@ class AudioService : LifecycleService() {
}

@MainThread
fun mute(muted: Boolean) {
if (muted) {
player?.volume = 0F
} else {
player?.volume = 1F
fun mute(muted: String?) {
muted?.toBoolean()?.let {
if (it) {
player?.volume = 0F
} else {
player?.volume = 1F
}
}
}

@MainThread
fun rate(rate: Float) {
player?.setPlaybackParameters(PlaybackParameters(rate))
fun volume(volume: String?) {
volume?.toFloat()?.let {
player?.volume = it
}
}

@MainThread
fun seekTo(seconds: Float) {
player?.seekTo((seconds * 1000F).toLong())
fun rate(rate: String?) {
rate?.toFloat()?.let {
player?.setPlaybackParameters(PlaybackParameters(it))
}
}

@MainThread
fun loadMetadata(epName: String, pdName: String, url: String) {
fun seekTo(seconds: String?) {
seconds?.toFloat()?.let {
player?.seekTo((it * 1000F).toLong())
}
}

@MainThread
fun loadMetadata(epName: String?, pdName: String?, url: String?) {
episodeName = epName
podcastName = pdName
imageUrl = url
Expand All @@ -225,9 +222,14 @@ class AudioService : LifecycleService() {
private fun preparePlayer() {
player?.playWhenReady = false

// Allows the data source to be seekable
val extractorsFactory: DefaultExtractorsFactory =
DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)

val dataSourceFactory = DefaultDataSourceFactory(this, BuildConfig.userAgent)
val streamUri = Uri.parse(currentPodcastUrl)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(streamUri)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(streamUri)
player?.prepare(mediaSource)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package to.dev.dev_android.media

import android.content.Context
import androidx.annotation.IntegerRes
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.util.NotificationUtil
import java.util.*

/*
* This subclass of PlayerNotificationManager customizes the controls available in the
* notification by overriding the getActions method.
*/
class PodcastPlayerNotificationManager(
context: Context,
channelId: String,
notificationId: Int,
mediaDescriptionAdapter: MediaDescriptionAdapter,
playerNotificationManager: NotificationListener
): PlayerNotificationManager(
context,
channelId,
notificationId,
mediaDescriptionAdapter,
playerNotificationManager) {

companion object {
fun createWithNotificationChannel(
context: Context,
channelId: String,
channelName: Int,
channelDescription: Int,
notificationId: Int,
mediaDescriptionAdapter: MediaDescriptionAdapter,
playerNotificationManager: NotificationListener): PodcastPlayerNotificationManager {

NotificationUtil.createNotificationChannel(
context, channelId, channelName, channelDescription, NotificationUtil.IMPORTANCE_LOW
)
return PodcastPlayerNotificationManager(
context, channelId, notificationId, mediaDescriptionAdapter, playerNotificationManager
)
}
}

override fun getActions(player: Player): List<String> {
var stringActions: List<String> = ArrayList()
stringActions += ACTION_REWIND
stringActions += if (shouldShowPauseButton(player)) {
ACTION_PAUSE
} else {
ACTION_PLAY
}
stringActions += ACTION_FAST_FORWARD
return stringActions
}

private fun shouldShowPauseButton(player: Player): Boolean {
val state = player.playbackState
return state != Player.STATE_ENDED && state != Player.STATE_IDLE && player.playWhenReady
}
}
63 changes: 28 additions & 35 deletions app/src/main/java/to/dev/dev_android/util/AndroidWebViewBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.IBinder
import android.util.Log
import android.webkit.JavascriptInterface
import android.widget.Toast
import com.google.gson.Gson
import to.dev.dev_android.media.AudioService
import to.dev.dev_android.view.main.view.CustomWebViewClient
import java.util.*
Expand Down Expand Up @@ -45,9 +46,27 @@ class AndroidWebViewBridge(private val context: Context) {
}

@JavascriptInterface
fun loadPodcast(url: String) {
fun podcastMessage(message: String) {
var map: Map<String, String> = HashMap()
map = Gson().fromJson(message, map.javaClass)
when(map["action"]) {
"load" -> loadPodcast(map["url"])
"play" -> audioService?.play(map["url"], map["seconds"])
"pause" -> audioService?.pause()
"seek" -> audioService?.seekTo(map["seconds"])
"rate" -> audioService?.rate(map["rate"])
"muted" -> audioService?.mute(map["muted"])
"volume" -> audioService?.volume(map["volume"])
"metadata" -> audioService?.loadMetadata(map["episodeName"], map["podcastName"], map["imageUrl"])
"terminate" -> terminatePodcast()
else -> logError("Podcast Error", "Unknown action")
}
}

fun loadPodcast(url: String?) {
if (url == null) return

AudioService.newIntent(context, url).also { intent ->
// This service will get converted to foreground service using the PlayerNotificationManager notification Id.
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}

Expand All @@ -59,50 +78,24 @@ class AndroidWebViewBridge(private val context: Context) {
timer.schedule(timeUpdateTask, 0, 1000)
}

@JavascriptInterface
fun playPodcast(seconds: String) {
audioService?.play()
}

@JavascriptInterface
fun pausePodcast() {
audioService?.pause()
}

@JavascriptInterface
fun metadataPodcast(episodeName: String, podcastName: String, imageUrl: String) {
audioService?.loadMetadata(episodeName, podcastName, imageUrl)
}

@JavascriptInterface
fun terminatePodcast() {
audioService?.pause()
context.unbindService(connection)
audioService = null
context.stopService(Intent(context, AudioService::class.java))
}

@JavascriptInterface
fun seekPodcast(seconds: Float) {
audioService?.seekTo(seconds)
}

@JavascriptInterface
fun ratePodcast(rate: Float) {
audioService?.rate(rate)
}

@JavascriptInterface
fun mutePodcast(muted: Boolean) {
audioService?.mute(muted)
}

fun podcastTimeUpdate() {
audioService?.let {
val time = it.currentTimeInSec() / 1000
val duration = it.durationInSec() / 1000
val message = mapOf("action" to "tick", "duration" to duration, "currentTime" to time)
webViewClient?.sendPodcastMessage(message)
if (duration < 0) {
// The duration overflows into a negative when waiting to load audio (initializing)
webViewClient?.sendPodcastMessage(mapOf("action" to "init"))
} else {
val message = mapOf("action" to "tick", "duration" to duration, "currentTime" to time)
webViewClient?.sendPodcastMessage(message)
}
}
}
}

0 comments on commit 651f9c7

Please sign in to comment.