Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Native Audio Player #72

Merged
merged 7 commits into from
May 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ dependencies {
implementation(Libs.kotlin_stdlib_jdk8)
implementation(Libs.browser)

implementation("com.google.android.exoplayer:exoplayer: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")

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package="to.dev.dev_android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<application
android:allowBackup="false"
Expand Down Expand Up @@ -34,6 +35,8 @@
android:scheme="https" />
</intent-filter>
</activity>

<service android:name=".media.AudioService" />
</application>

</manifest>
233 changes: 233 additions & 0 deletions app/src/main/java/to/dev/dev_android/media/AudioService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package to.dev.dev_android.media

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.net.Uri
import android.os.Binder
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.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import to.dev.dev_android.R
import to.dev.dev_android.base.BuildConfig

class AudioService : LifecycleService() {
private val binder = AudioServiceBinder()

private var currentPodcastUrl: String? = null
private var episodeName: String? = null
private var podcastName: String? = null
private var imageUrl: String? = null

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
get() = this@AudioService
}

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

const val argPodcastUrl = "ARG_PODCAST_URL"
const val playbackChannelId = "playback_channel"
const val mediaSessionTag = "DEV Community Session"
const val playbackNotificationId = 1
const val incrementMs = 15000
}

override fun onBind(intent: Intent): IBinder {
super.onBind(intent)

val newPodcastUrl = intent.getStringExtra(argPodcastUrl)
if (currentPodcastUrl != newPodcastUrl) {
currentPodcastUrl = newPodcastUrl
preparePlayer()
}

return binder
}

override fun onCreate() {
super.onCreate()

player = SimpleExoPlayer.Builder(this).build()
player?.audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.CONTENT_TYPE_SPEECH)
.build()

playerNotificationManager = PlayerNotificationManager.createWithNotificationChannel(
applicationContext,
playbackChannelId,
R.string.app_name,
R.string.playback_channel_description,
playbackNotificationId,
object : PlayerNotificationManager.MediaDescriptionAdapter {
override fun getCurrentContentTitle(player: Player): String {
return episodeName ?: getString(R.string.app_name)
}

@Nullable
override fun createCurrentContentIntent(player: Player): PendingIntent? = null

@Nullable
override fun getCurrentContentText(player: Player): String? {
return podcastName ?: getString(R.string.playback_channel_description)
}

@Nullable
override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? {
return null
}
},
object : PlayerNotificationManager.NotificationListener {
override fun onNotificationStarted(
notificationId: Int,
notification: Notification
) {
startForeground(notificationId, notification)
}

override fun onNotificationCancelled(notificationId: Int) {
stopSelf()
}

override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
ongoing: Boolean
) {
if (ongoing) {
// Make sure the service will not get destroyed while playing media.
startForeground(notificationId, notification)
} else {
// Make notification cancellable.
stopForeground(false)
}
}
}
).apply {
// Omit skip previous and next actions.
setUseNavigationActions(false)

// Add stop action.
setUseStopAction(true)

setUseNavigationActionsInCompactView(false)

setFastForwardIncrementMs(incrementMs.toLong())
setRewindIncrementMs(incrementMs.toLong())

setPlayer(player)
}

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

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() {
player?.playWhenReady = true
}

@MainThread
fun pause() {
player?.playWhenReady = false
}

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

@MainThread
fun rate(rate: Float) {
player?.setPlaybackParameters(PlaybackParameters(rate))
}

@MainThread
fun seekTo(seconds: Float) {
player?.seekTo((seconds * 1000F).toLong())
}

@MainThread
fun loadMetadata(epName: String, pdName: String, url: String) {
episodeName = epName
podcastName = pdName
imageUrl = url
}

@MainThread
fun currentTimeInSec() : Long {
return player?.currentPosition ?: 0
}

@MainThread
fun durationInSec() : Long {
return player?.duration ?: 0L
}

@MainThread
private fun preparePlayer() {
player?.playWhenReady = false

val dataSourceFactory = DefaultDataSourceFactory(this, BuildConfig.userAgent)
val streamUri = Uri.parse(currentPodcastUrl)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(streamUri)
player?.prepare(mediaSource)
}
}
97 changes: 84 additions & 13 deletions app/src/main/java/to/dev/dev_android/util/AndroidWebViewBridge.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
package to.dev.dev_android.util

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.*
import android.os.IBinder
import android.util.Log
import android.webkit.JavascriptInterface
import android.widget.Toast
import to.dev.dev_android.BuildConfig
import to.dev.dev_android.media.AudioService
import to.dev.dev_android.view.main.view.CustomWebViewClient
import java.util.*

/**
* This class currently is empty because more methods would be added to it
* when new bridge functionalities are added.
*/
class AndroidWebViewBridge(private val context: Context) {
/**
* Every method that has to be accessed from web-view needs to be marked with
* `@JavascriptInterface`.
* This is currently just a sample method which logs an error to Logcat.
*/

var webViewClient: CustomWebViewClient? = null
private val timer = Timer()

// audioService is initialized when onServiceConnected is executed after/during binding is done
private var audioService: AudioService? = null
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AudioService.AudioServiceBinder
audioService = binder.service
}

override fun onServiceDisconnected(name: ComponentName?) {
audioService = null
}
}

@JavascriptInterface
fun logError(errorTag: String, errorMessage: String) {
Log.e(errorTag, errorMessage)
Expand All @@ -34,4 +43,66 @@ class AndroidWebViewBridge(private val context: Context) {
fun showToast(message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}

@JavascriptInterface
fun loadPodcast(url: String) {
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)
}

val timeUpdateTask = object: TimerTask() {
override fun run() {
podcastTimeUpdate()
}
}
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)
}
}
}