From fb57513439b3df67640222cbe54f70af2d46776e Mon Sep 17 00:00:00 2001 From: Fernando Valverde Date: Thu, 23 Apr 2020 09:45:05 -0600 Subject: [PATCH 1/4] First commit for ExoPlayer implementation --- app/build.gradle.kts | 2 + .../dev_android/util/AndroidWebViewBridge.kt | 68 ++++++++++++++++++- .../view/main/view/CustomWebViewClient.kt | 17 +++++ .../view/main/view/MainActivity.kt | 2 +- gradle.properties | 1 + 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7f4141e..1c26f65 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") + testImplementation(Libs.junit) androidTestImplementation(Libs.androidx_test_runner) androidTestImplementation(Libs.espresso_core) 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..4c59c5c 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 @@ -3,16 +3,37 @@ package to.dev.dev_android.util import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.net.Uri +import android.os.Handler import android.util.Log import android.webkit.JavascriptInterface import android.widget.Toast -import to.dev.dev_android.BuildConfig +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.source.ExtractorMediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import to.dev.dev_android.base.BuildConfig +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) { +class AndroidWebViewBridge(private val context: Context) : Player.EventListener { + + var webViewClient: CustomWebViewClient? = null + private var player: SimpleExoPlayer? = null + private val timer = java.util.Timer() + private val timeUpdateTask = object: TimerTask() { + override fun run() { + val mainHandler = Handler(context.mainLooper) + mainHandler.post(Runnable { podcastTimeUpdate() }) + } + } + /** * Every method that has to be accessed from web-view needs to be marked with * `@JavascriptInterface`. @@ -34,4 +55,47 @@ class AndroidWebViewBridge(private val context: Context) { fun showToast(message: String) { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } + + @JavascriptInterface + fun loadPodcast(url: String) { + try { + if (player == null) { + player = SimpleExoPlayer.Builder(context).build() + player?.addListener(this) + } + + var dataSourceFactory = DefaultDataSourceFactory(context, BuildConfig.userAgent) + var streamUri = Uri.parse(url) + var mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(streamUri) + player?.prepare(mediaSource) + } catch (e: Exception) { + Log.e("PODCAST", e.toString()) + } + } + + @JavascriptInterface + fun playPodcast(seconds: String) { + player?.setPlayWhenReady(true) + timer.schedule(timeUpdateTask, 0, 1000) + } + + @JavascriptInterface + fun pausePodcast() { + player?.setPlayWhenReady(false) + } + + @JavascriptInterface + fun terminatePodcast() { + timer.cancel() + player?.release() + player = null + } + + fun podcastTimeUpdate() { + val position = (player?.contentPosition ?: 0 / 1000.0).toString() + val duration = (player?.duration ?: 0 / 1000.0).toString() + val message = mapOf("action" to "tick", "duration" to duration, "currentTime" to position) + Log.i("PODCAST", message.toString()) + 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 6d03a15..b9395b5 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,19 +4,24 @@ 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 to.dev.dev_android.R class CustomWebViewClient( private val context: Context, + private val view: WebView, private val onPageFinish: () -> Unit ) : WebViewClient() { private val overrideUrlList = listOf( "://dev.to", + "://fdoxyz.ngrok.io", "api.twitter.com/oauth", "api.twitter.com/account/login_verification", "github.com/login", @@ -52,4 +57,16 @@ class CustomWebViewClient( return true } + + fun sendPodcastMessage(message: Map) { + val jsonMessage = JSONObject(message).toString() + val javascript = "document.getElementById('audiocontent').setAttribute('data-podcast', '$jsonMessage')" + view?.evaluateJavascript(javascript) { result -> + if (result != "null") { + Log.i("PODCAST", "Message sent successfully") + } else { + Log.w("PODCAST", "Message failed to be sent") + } + } + } } \ 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 2da57f5..c91f0b7 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 @@ -59,7 +59,7 @@ class MainActivity : BaseActivity(), CustomWebChromeClient. binding.webView.settings.userAgentString = BuildConfig.userAgent binding.webView.addJavascriptInterface(webViewBridge, "AndroidBridge") - binding.webView.webViewClient = CustomWebViewClient(this@MainActivity) { + binding.webView.webViewClient = CustomWebViewClient(this@MainActivity, binding.webView) { binding.splash.visibility = View.GONE } binding.webView.webChromeClient = CustomWebChromeClient(BuildConfig.baseUrl, this) diff --git a/gradle.properties b/gradle.properties index dbe48e0..f9aee1f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,6 +28,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 From 673a20df8d72c79fb1cc2907608c493e27727523 Mon Sep 17 00:00:00 2001 From: Fernando Valverde Date: Sat, 25 Apr 2020 18:40:17 -0600 Subject: [PATCH 2/4] Native Bridge controls for the player --- .../dev_android/util/AndroidWebViewBridge.kt | 89 ++++++++++++------- .../view/main/view/CustomWebViewClient.kt | 17 ++-- .../view/main/view/MainActivity.kt | 1 + 3 files changed, 65 insertions(+), 42 deletions(-) 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 4c59c5c..ceec934 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 @@ -9,36 +9,21 @@ import android.util.Log import android.webkit.JavascriptInterface import android.widget.Toast import com.google.android.exoplayer2.* -import com.google.android.exoplayer2.source.ExtractorMediaSource +import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.source.ProgressiveMediaSource -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory import to.dev.dev_android.base.BuildConfig 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) : Player.EventListener { +class AndroidWebViewBridge(private val context: Context) { var webViewClient: CustomWebViewClient? = null + private val timer = Timer() + private var player: SimpleExoPlayer? = null - private val timer = java.util.Timer() - private val timeUpdateTask = object: TimerTask() { - override fun run() { - val mainHandler = Handler(context.mainLooper) - mainHandler.post(Runnable { podcastTimeUpdate() }) - } - } + private var playerHandler: Handler? = null - /** - * 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. - */ @JavascriptInterface fun logError(errorTag: String, errorMessage: String) { Log.e(errorTag, errorMessage) @@ -60,13 +45,12 @@ class AndroidWebViewBridge(private val context: Context) : Player.EventListener fun loadPodcast(url: String) { try { if (player == null) { - player = SimpleExoPlayer.Builder(context).build() - player?.addListener(this) + initPlayer() } - var dataSourceFactory = DefaultDataSourceFactory(context, BuildConfig.userAgent) - var streamUri = Uri.parse(url) - var mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(streamUri) + val dataSourceFactory = DefaultDataSourceFactory(context, BuildConfig.userAgent) + val streamUri = Uri.parse(url) + val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(streamUri) player?.prepare(mediaSource) } catch (e: Exception) { Log.e("PODCAST", e.toString()) @@ -75,27 +59,64 @@ class AndroidWebViewBridge(private val context: Context) : Player.EventListener @JavascriptInterface fun playPodcast(seconds: String) { - player?.setPlayWhenReady(true) - timer.schedule(timeUpdateTask, 0, 1000) + player?.playWhenReady = true } @JavascriptInterface fun pausePodcast() { - player?.setPlayWhenReady(false) + player?.playWhenReady = false } @JavascriptInterface fun terminatePodcast() { - timer.cancel() player?.release() player = null + playerHandler = null + } + + @JavascriptInterface + fun seekPodcast(seconds: Float) { + player?.seekTo((seconds * 1000F).toLong()) + } + + @JavascriptInterface + fun ratePodcast(rate: Float) { + player?.setPlaybackParameters(PlaybackParameters(rate)) + } + + @JavascriptInterface + fun mutePodcast(muted: Boolean) { + if (muted) { + player?.volume = 0F + } else { + player?.volume = 1F + } + } + + fun initPlayer() { + player = SimpleExoPlayer.Builder(context).build() + player?.audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.CONTENT_TYPE_SPEECH) + .build() + + // Creates a task that will update about every second. Has to use the player's thread + // https://exoplayer.dev/hello-world.html#a-note-on-threading + playerHandler = Handler(player?.applicationLooper) + val timeUpdateTask = object: TimerTask() { + override fun run() { + playerHandler?.post(Runnable { podcastTimeUpdate() }) + } + } + timer.schedule(timeUpdateTask, 0, 1000) } fun podcastTimeUpdate() { - val position = (player?.contentPosition ?: 0 / 1000.0).toString() - val duration = (player?.duration ?: 0 / 1000.0).toString() - val message = mapOf("action" to "tick", "duration" to duration, "currentTime" to position) - Log.i("PODCAST", message.toString()) - webViewClient?.sendPodcastMessage(message) + if (player != null) { + val time = player!!.currentPosition / 1000 + val duration = player!!.duration / 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 4ef0477..57e2f50 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 @@ -22,7 +22,6 @@ class CustomWebViewClient( private val overrideUrlList = listOf( "://dev.to", - "://fdoxyz.ngrok.io", "api.twitter.com/oauth", "api.twitter.com/account/login_verification", "github.com/login", @@ -79,15 +78,17 @@ class CustomWebViewClient( return true } - fun sendPodcastMessage(message: Map) { + fun sendPodcastMessage(message: Map) { val jsonMessage = JSONObject(message).toString() val javascript = "document.getElementById('audiocontent').setAttribute('data-podcast', '$jsonMessage')" - view?.evaluateJavascript(javascript) { result -> - if (result != "null") { - Log.i("PODCAST", "Message sent successfully") - } else { - Log.w("PODCAST", "Message failed to be sent") + view?.post(Runnable { + view?.evaluateJavascript(javascript) { result -> + if (result != "null") { + Log.i("PODCAST", "Message sent successfully") + } else { + Log.w("PODCAST", "Message failed to be sent") + } } - } + }) } } \ 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 357154f..f64ebba 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 @@ -72,6 +72,7 @@ class MainActivity : BaseActivity(), CustomWebChromeClient. binding.webView.webViewClient = CustomWebViewClient(this@MainActivity, binding.webView) { binding.splash.visibility = View.GONE } + webViewBridge.webViewClient = binding.webView.webViewClient as? CustomWebViewClient binding.webView.webChromeClient = CustomWebChromeClient(BuildConfig.baseUrl, this) } From 9f81090cc1c5acf2ca1e064c39c13815c37aab95 Mon Sep 17 00:00:00 2001 From: Fernando Valverde Date: Thu, 30 Apr 2020 03:08:35 -0600 Subject: [PATCH 3/4] Extracts audio playback into Foreground Service & min SDK API bumped to 21 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 3 + .../to/dev/dev_android/media/AudioService.kt | 233 ++++++++++++++++++ .../dev_android/util/AndroidWebViewBridge.kt | 96 +++----- .../view/main/view/CustomWebViewClient.kt | 8 +- .../view/main/view/MainActivity.kt | 5 +- app/src/main/res/values/strings.xml | 1 + gradle.properties | 2 +- 8 files changed, 284 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/to/dev/dev_android/media/AudioService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48fefe0..3f4b269 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { 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 f58529d..c95c3e0 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 ceec934..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,18 +1,11 @@ package to.dev.dev_android.util -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.net.Uri -import android.os.Handler +import android.content.* +import android.os.IBinder import android.util.Log import android.webkit.JavascriptInterface import android.widget.Toast -import com.google.android.exoplayer2.* -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.source.ProgressiveMediaSource -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory -import to.dev.dev_android.base.BuildConfig +import to.dev.dev_android.media.AudioService import to.dev.dev_android.view.main.view.CustomWebViewClient import java.util.* @@ -21,8 +14,18 @@ class AndroidWebViewBridge(private val context: Context) { var webViewClient: CustomWebViewClient? = null private val timer = Timer() - private var player: SimpleExoPlayer? = null - private var playerHandler: Handler? = null + // 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) { @@ -43,78 +46,61 @@ class AndroidWebViewBridge(private val context: Context) { @JavascriptInterface fun loadPodcast(url: String) { - try { - if (player == null) { - initPlayer() - } + 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 dataSourceFactory = DefaultDataSourceFactory(context, BuildConfig.userAgent) - val streamUri = Uri.parse(url) - val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(streamUri) - player?.prepare(mediaSource) - } catch (e: Exception) { - Log.e("PODCAST", e.toString()) + val timeUpdateTask = object: TimerTask() { + override fun run() { + podcastTimeUpdate() + } } + timer.schedule(timeUpdateTask, 0, 1000) } @JavascriptInterface fun playPodcast(seconds: String) { - player?.playWhenReady = true + audioService?.play() } @JavascriptInterface fun pausePodcast() { - player?.playWhenReady = false + audioService?.pause() + } + + @JavascriptInterface + fun metadataPodcast(episodeName: String, podcastName: String, imageUrl: String) { + audioService?.loadMetadata(episodeName, podcastName, imageUrl) } @JavascriptInterface fun terminatePodcast() { - player?.release() - player = null - playerHandler = null + audioService?.pause() + context.unbindService(connection) + audioService = null + context.stopService(Intent(context, AudioService::class.java)) } @JavascriptInterface fun seekPodcast(seconds: Float) { - player?.seekTo((seconds * 1000F).toLong()) + audioService?.seekTo(seconds) } @JavascriptInterface fun ratePodcast(rate: Float) { - player?.setPlaybackParameters(PlaybackParameters(rate)) + audioService?.rate(rate) } @JavascriptInterface fun mutePodcast(muted: Boolean) { - if (muted) { - player?.volume = 0F - } else { - player?.volume = 1F - } - } - - fun initPlayer() { - player = SimpleExoPlayer.Builder(context).build() - player?.audioAttributes = AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.CONTENT_TYPE_SPEECH) - .build() - - // Creates a task that will update about every second. Has to use the player's thread - // https://exoplayer.dev/hello-world.html#a-note-on-threading - playerHandler = Handler(player?.applicationLooper) - val timeUpdateTask = object: TimerTask() { - override fun run() { - playerHandler?.post(Runnable { podcastTimeUpdate() }) - } - } - timer.schedule(timeUpdateTask, 0, 1000) + audioService?.mute(muted) } fun podcastTimeUpdate() { - if (player != null) { - val time = player!!.currentPosition / 1000 - val duration = player!!.duration / 1000 + 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 57e2f50..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 @@ -82,13 +82,7 @@ class CustomWebViewClient( val jsonMessage = JSONObject(message).toString() val javascript = "document.getElementById('audiocontent').setAttribute('data-podcast', '$jsonMessage')" view?.post(Runnable { - view?.evaluateJavascript(javascript) { result -> - if (result != "null") { - Log.i("PODCAST", "Message sent successfully") - } else { - Log.w("PODCAST", "Message failed to be sent") - } - } + 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 f64ebba..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,10 +69,11 @@ class MainActivity : BaseActivity(), CustomWebChromeClient. binding.webView.settings.userAgentString = BuildConfig.userAgent binding.webView.addJavascriptInterface(webViewBridge, "AndroidBridge") - binding.webView.webViewClient = CustomWebViewClient(this@MainActivity, binding.webView) { + val webViewClient = CustomWebViewClient(this@MainActivity, binding.webView) { binding.splash.visibility = View.GONE } - webViewBridge.webViewClient = binding.webView.webViewClient as? CustomWebViewClient + 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 9cf5ef7..b3620ed 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ pusher.instanceId=cdaf9857-fad0-4bfb-b360-64c1b2693ef3 # Android release configurations android.targetSdkVersion=28 android.compileSdkVersion=28 -android.minSdkVersion=18 +android.minSdkVersion=21 android.applicationId=to.dev.dev_android android.versionCode=6 android.versionName=1.2.2 From 3a0af3f37a82d3d802c6c315619cce8e62b76661 Mon Sep 17 00:00:00 2001 From: Fernando Valverde Date: Thu, 30 Apr 2020 03:29:56 -0600 Subject: [PATCH 4/4] back to API 18 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b3620ed..9cf5ef7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ pusher.instanceId=cdaf9857-fad0-4bfb-b360-64c1b2693ef3 # Android release configurations android.targetSdkVersion=28 android.compileSdkVersion=28 -android.minSdkVersion=21 +android.minSdkVersion=18 android.applicationId=to.dev.dev_android android.versionCode=6 android.versionName=1.2.2