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