diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6114758..3f4b269 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 892007b..04c3149 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
package="to.dev.dev_android">
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/to/dev/dev_android/media/AudioService.kt b/app/src/main/java/to/dev/dev_android/media/AudioService.kt
new file mode 100644
index 0000000..428791b
--- /dev/null
+++ b/app/src/main/java/to/dev/dev_android/media/AudioService.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/to/dev/dev_android/util/AndroidWebViewBridge.kt b/app/src/main/java/to/dev/dev_android/util/AndroidWebViewBridge.kt
index 0ab1877..ffbdd3a 100644
--- a/app/src/main/java/to/dev/dev_android/util/AndroidWebViewBridge.kt
+++ b/app/src/main/java/to/dev/dev_android/util/AndroidWebViewBridge.kt
@@ -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)
@@ -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)
+ }
+ }
}
diff --git a/app/src/main/java/to/dev/dev_android/view/main/view/CustomWebViewClient.kt b/app/src/main/java/to/dev/dev_android/view/main/view/CustomWebViewClient.kt
index 7981b35..1181e25 100644
--- a/app/src/main/java/to/dev/dev_android/view/main/view/CustomWebViewClient.kt
+++ b/app/src/main/java/to/dev/dev_android/view/main/view/CustomWebViewClient.kt
@@ -4,16 +4,19 @@ import android.content.Context
import android.graphics.Color
import android.net.Uri
import android.os.Build
+import android.util.Log
import android.view.View
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.browser.customtabs.CustomTabsIntent
+import org.json.JSONObject
import com.pusher.pushnotifications.PushNotifications
import java.lang.Exception
class CustomWebViewClient(
private val context: Context,
+ private val view: WebView,
private val onPageFinish: () -> Unit
) : WebViewClient() {
@@ -74,4 +77,12 @@ class CustomWebViewClient(
return true
}
+
+ fun sendPodcastMessage(message: Map) {
+ val jsonMessage = JSONObject(message).toString()
+ val javascript = "document.getElementById('audiocontent').setAttribute('data-podcast', '$jsonMessage')"
+ view?.post(Runnable {
+ view?.evaluateJavascript(javascript, null)
+ })
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/to/dev/dev_android/view/main/view/MainActivity.kt b/app/src/main/java/to/dev/dev_android/view/main/view/MainActivity.kt
index 0ac65da..915a8fd 100644
--- a/app/src/main/java/to/dev/dev_android/view/main/view/MainActivity.kt
+++ b/app/src/main/java/to/dev/dev_android/view/main/view/MainActivity.kt
@@ -69,9 +69,11 @@ class MainActivity : BaseActivity(), CustomWebChromeClient.
binding.webView.settings.userAgentString = BuildConfig.userAgent
binding.webView.addJavascriptInterface(webViewBridge, "AndroidBridge")
- binding.webView.webViewClient = CustomWebViewClient(this@MainActivity) {
+ val webViewClient = CustomWebViewClient(this@MainActivity, binding.webView) {
binding.splash.visibility = View.GONE
}
+ binding.webView.webViewClient = webViewClient
+ webViewBridge.webViewClient = webViewClient
binding.webView.webChromeClient = CustomWebChromeClient(BuildConfig.baseUrl, this)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7c15787..dec7cde 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,5 @@
DEV Community
Splash
+ Podcast Player
diff --git a/gradle.properties b/gradle.properties
index 44a91cb..61e2c2a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -29,6 +29,7 @@ org.gradle.jvmargs=-Xmx1536m
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+kotlin.setJvmTargetFromAndroidCompileOptions = true
android.useAndroidX=true
android.enableJetifier=true
studio.projectview=true