From 7ea39179ef06400ff5db38062264b7d1c266467f Mon Sep 17 00:00:00 2001 From: y20k Date: Fri, 19 Feb 2021 18:29:46 +0100 Subject: [PATCH] Implement PlayerNotificationManager and MediaSessionConnector --- AUTHORS.md | 2 + app/build.gradle | 32 +- app/proguard-rules.pro | 4 +- app/src/main/AndroidManifest.xml | 2 +- app/src/main/java/org/y20k/transistor/Keys.kt | 4 +- .../org/y20k/transistor/PlayerFragment.kt | 35 +- .../PlayerServiceStarterActivity.kt | 1 + .../transistor/helpers/CollectionHelper.kt | 21 + .../transistor/helpers/NotificationHelper.kt | 188 +++--- .../transistor/playback/PlayerController.kt | 106 ++++ .../{ => playback}/PlayerService.kt | 553 ++++++++---------- .../drawable/ic_notification_clear_36dp.xml | 9 + app/src/main/res/values/values.xml | 6 + .../res/xml/allowed_media_browser_callers.xml | 2 +- build.gradle | 4 +- metadata/en-US/changelogs/82.txt | 6 + 16 files changed, 516 insertions(+), 459 deletions(-) create mode 100644 app/src/main/java/org/y20k/transistor/playback/PlayerController.kt rename app/src/main/java/org/y20k/transistor/{ => playback}/PlayerService.kt (61%) create mode 100644 app/src/main/res/drawable/ic_notification_clear_36dp.xml create mode 100644 app/src/main/res/values/values.xml create mode 100644 metadata/en-US/changelogs/82.txt diff --git a/AUTHORS.md b/AUTHORS.md index 58ca7a16..66a9c17d 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -23,6 +23,8 @@ Czech version: [Paper Mountain Studio ](https://github.com/PaperMountainStudio) Dutch version: [hypothermic](https://github.com/hypothermic) | [weblate version history](https://hosted.weblate.org/changes/?lang=nl&project=transistor) +Esperanto version: [Jakub Fabijan](https://hosted.weblate.org/user/JakubFabijan/) | [weblate version history](https://hosted.weblate.org/changes/?lang=eo&project=transistor) + French version: [M2ck](https://github.com/M2ck), [ButterflyOfFire](https://github.com/BoFFire) | [weblate version history](https://hosted.weblate.org/changes/?lang=fr&project=transistor) German version: [y20k](https://github.com/y20k) | [weblate version history](https://hosted.weblate.org/changes/?lang=de&project=transistor) diff --git a/app/build.gradle b/app/build.gradle index 929ad5d9..ed126ad3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,6 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-parcelize' android { @@ -13,7 +13,7 @@ android { targetSdkVersion 30 versionCode 82 versionName '4.0.11' - resConfigs "en", "ar", "ca", "cs","de", "el", "es", "eu", "fr", "he", "hr", "id", "in", "it", "ja", "kab", "nb-rNO", "nl", "pa", "pl", "pt", "pt-rBR", "ru", "sk", "sl", "sr", "th", "tr", "uk", "zh-rCN" + resConfigs "en", "ar", "ca", "cs","de", "el", "eo","es", "eu", "fr", "he", "hr", "id", "in", "it", "ja", "kab", "nb-rNO", "nl", "pa", "pl", "pt", "pt-rBR", "ru", "sk", "sl", "sr", "th", "tr", "uk", "zh-rCN" } kotlinOptions { @@ -62,31 +62,31 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9" - implementation "com.google.android.material:material:1.2.1" + def coroutinesVersion = "1.3.9" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "androidx.activity:activity-ktx:1.1.0" + implementation "com.google.android.material:material:1.3.0" + implementation "android.arch.work:work-runtime-ktx:1.0.1" + + implementation "androidx.activity:activity-ktx:1.2.0" implementation "androidx.appcompat:appcompat:1.2.0" implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation "androidx.core:core-ktx:1.3.2" - implementation 'androidx.navigation:navigation-fragment-ktx:2.3.2' - implementation 'androidx.navigation:navigation-ui-ktx:2.3.2' implementation "androidx.palette:palette-ktx:1.0.0" implementation "androidx.preference:preference-ktx:1.1.1" implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation "android.arch.work:work-runtime-ktx:1.0.1" + def navigationVersion = "2.3.3" + implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion" + implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion" - implementation "com.google.android.exoplayer:exoplayer:2.12.3" - implementation "com.google.android.exoplayer:extension-mediasession:2.12.3" - implementation "com.google.code.gson:gson:2.8.6" + def exoplayerVersion = "2.13.0" + implementation "com.google.android.exoplayer:exoplayer:$exoplayerVersion" + implementation "com.google.android.exoplayer:extension-mediasession:$exoplayerVersion" + implementation "com.google.code.gson:gson:2.8.6" implementation 'com.android.volley:volley:1.1.1' } - -androidExtensions { - experimental = true -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 073bc506..86a2348e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,6 +24,6 @@ # Preserve the core classes - because they need to be de-/serialized with GSON -keep public class org.y20k.transistor.core.** { *; } --keep public class org.y20k.transistor.PlayerService { *; } +-keep public class org.y20k.transistor.playback.PlayerService { *; } --keep public class org.y20k.transistor.search.RadioBrowserResult { *; } \ No newline at end of file +-keep public class org.y20k.transistor.search.RadioBrowserResult { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b313f7f4..788a451b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -114,7 +114,7 @@ diff --git a/app/src/main/java/org/y20k/transistor/Keys.kt b/app/src/main/java/org/y20k/transistor/Keys.kt index 1408bd81..890b5eb7 100644 --- a/app/src/main/java/org/y20k/transistor/Keys.kt +++ b/app/src/main/java/org/y20k/transistor/Keys.kt @@ -40,8 +40,8 @@ object Keys { val PLAYBACK_SPEEDS = arrayOf(1.0f, 1.2f, 1.4f, 1.6f, 1.8f, 2.0f) // notification - const val NOTIFICATION_NOW_PLAYING_ID: Int = 42 - const val NOTIFICATION_NOW_PLAYING_CHANNEL: String = "notificationChannelIdPlaybackChannel" + const val NOW_PLAYING_NOTIFICATION_ID: Int = 42 + const val NOW_PLAYING_NOTIFICATION_CHANNEL_ID: String = "notificationChannelIdPlaybackChannel" // intent actions const val ACTION_SHOW_PLAYER: String = "org.y20k.transistor.action.SHOW_PLAYER" diff --git a/app/src/main/java/org/y20k/transistor/PlayerFragment.kt b/app/src/main/java/org/y20k/transistor/PlayerFragment.kt index 55faa8ed..c5ad3016 100644 --- a/app/src/main/java/org/y20k/transistor/PlayerFragment.kt +++ b/app/src/main/java/org/y20k/transistor/PlayerFragment.kt @@ -40,7 +40,6 @@ import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.lifecycle.Observer @@ -56,6 +55,8 @@ import org.y20k.transistor.dialogs.FindStationDialog import org.y20k.transistor.dialogs.YesNoDialog import org.y20k.transistor.extensions.isActive import org.y20k.transistor.helpers.* +import org.y20k.transistor.playback.PlayerController +import org.y20k.transistor.playback.PlayerService import org.y20k.transistor.ui.LayoutHolder import org.y20k.transistor.ui.PlayerState import java.util.* @@ -81,6 +82,7 @@ class PlayerFragment: Fragment(), CoroutineScope, private lateinit var collectionViewModel: CollectionViewModel private lateinit var layout: LayoutHolder private lateinit var collectionAdapter: CollectionAdapter + private lateinit var playerController: PlayerController private var collection: Collection = Collection() private var playerServiceConnected: Boolean = false private var onboarding: Boolean = false @@ -197,7 +199,7 @@ class PlayerFragment: Fragment(), CoroutineScope, override fun onStop() { super.onStop() // (see "stay in sync with the MediaSession") - MediaControllerCompat.getMediaController(activity as Activity)?.unregisterCallback(mediaControllerCallback) + playerController.unregisterCallback(mediaControllerCallback) mediaBrowser.disconnect() playerServiceConnected = false } @@ -371,16 +373,16 @@ class PlayerFragment: Fragment(), CoroutineScope, // set up sleep timer start button layout.sheetSleepTimerStartButtonView.setOnClickListener { - val playbackState: PlaybackStateCompat = MediaControllerCompat.getMediaController(activity as Activity).playbackState + val playbackState: PlaybackStateCompat = playerController.getPlaybackState() when (playbackState.isActive) { - true -> MediaControllerCompat.getMediaController(activity as Activity).sendCommand(Keys.CMD_START_SLEEP_TIMER, null, null) + true -> playerController.startSleepTimer() false -> Toast.makeText(activity as Context, R.string.toastmessage_sleep_timer_unable_to_start, Toast.LENGTH_LONG).show() } } // set up sleep timer cancel button layout.sheetSleepTimerCancelButtonView.setOnClickListener { - MediaControllerCompat.getMediaController(activity as Activity).sendCommand(Keys.CMD_CANCEL_SLEEP_TIMER, null, null) + playerController.cancelSleepTimer() layout.sleepTimerRunningViews.isGone = true } @@ -394,17 +396,14 @@ class PlayerFragment: Fragment(), CoroutineScope, // get player state playerState = PreferencesHelper.loadPlayerState(activity as Context) - // get reference to media controller - val mediaController = MediaControllerCompat.getMediaController(activity as Activity) - // main play/pause button layout.playButtonView.setOnClickListener { onPlayButtonTapped(playerState.stationUuid, playerState.playbackState) - // onPlayButtonTapped(playerState.stationUuid, mediaController.playbackState.state) todo remove + //onPlayButtonTapped(playerState.stationUuid, playerController.getPlaybackState().state) // todo remove } // register a callback to stay in sync - mediaController.registerCallback(mediaControllerCallback) + playerController.registerCallback(mediaControllerCallback) } @@ -442,8 +441,8 @@ class PlayerFragment: Fragment(), CoroutineScope, layout.updatePlayerViews(activity as Context, station, playerState.playbackState) // start / pause playback when (startPlayback) { - true -> MediaControllerCompat.getMediaController(activity as Activity).transportControls.playFromMediaId(station.uuid, null) - false -> MediaControllerCompat.getMediaController(activity as Activity).transportControls.pause() + true -> playerController.play(station.uuid) + false -> playerController.pause() } } @@ -500,13 +499,13 @@ class PlayerFragment: Fragment(), CoroutineScope, private fun handleStartPlayer() { val intent: Intent = (activity as Activity).intent if (intent.hasExtra(Keys.EXTRA_START_LAST_PLAYED_STATION)) { - MediaControllerCompat.getMediaController(activity as Activity).transportControls.playFromMediaId(playerState.stationUuid, null) + playerController.play(playerState.stationUuid) } else if (intent.hasExtra(Keys.EXTRA_STATION_UUID)) { - val uuid: String? = intent.getStringExtra(Keys.EXTRA_STATION_UUID) - MediaControllerCompat.getMediaController(activity as Activity).transportControls.playFromMediaId(uuid, null) + val uuid: String = intent.getStringExtra(Keys.EXTRA_STATION_UUID) ?: String() + playerController.play(uuid) } else if (intent.hasExtra(Keys.EXTRA_STREAM_URI)) { val streamUri: String = intent.getStringExtra(Keys.EXTRA_STREAM_URI) ?: String() - MediaControllerCompat.getMediaController(activity as Activity).sendCommand(Keys.CMD_PLAY_STREAM, bundleOf(Pair(Keys.KEY_STREAM_URI, streamUri)), null) + playerController.playStreamDirectly(streamUri) } } @@ -578,6 +577,8 @@ class PlayerFragment: Fragment(), CoroutineScope, val mediaController = MediaControllerCompat(activity as Context, token) // save the controller MediaControllerCompat.setMediaController(activity as Activity, mediaController) + // initialize playerController + playerController = PlayerController(mediaController) } playerServiceConnected = true @@ -664,7 +665,7 @@ class PlayerFragment: Fragment(), CoroutineScope, private val periodicProgressUpdateRequestRunnable: Runnable = object : Runnable { override fun run() { // request current playback position - MediaControllerCompat.getMediaController(activity as Activity).sendCommand(Keys.CMD_REQUEST_PROGRESS_UPDATE, null, resultReceiver) + playerController.requestProgressUpdate(resultReceiver) // use the handler to start runnable again after specified delay handler.postDelayed(this, 500) } diff --git a/app/src/main/java/org/y20k/transistor/PlayerServiceStarterActivity.kt b/app/src/main/java/org/y20k/transistor/PlayerServiceStarterActivity.kt index 41fed167..57d5aa36 100644 --- a/app/src/main/java/org/y20k/transistor/PlayerServiceStarterActivity.kt +++ b/app/src/main/java/org/y20k/transistor/PlayerServiceStarterActivity.kt @@ -17,6 +17,7 @@ package org.y20k.transistor import android.app.Activity import android.content.Intent import android.os.Bundle +import org.y20k.transistor.playback.PlayerService /* diff --git a/app/src/main/java/org/y20k/transistor/helpers/CollectionHelper.kt b/app/src/main/java/org/y20k/transistor/helpers/CollectionHelper.kt index e5cf6a4e..38224741 100644 --- a/app/src/main/java/org/y20k/transistor/helpers/CollectionHelper.kt +++ b/app/src/main/java/org/y20k/transistor/helpers/CollectionHelper.kt @@ -16,7 +16,9 @@ package org.y20k.transistor.helpers import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.net.Uri +import android.os.Bundle import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat @@ -456,6 +458,25 @@ object CollectionHelper { } + /* Creates description for a single episode - used in MediaSessionConnector */ + fun buildStationMediaDescription(context: Context, station: Station): MediaDescriptionCompat { + val coverBitmap: Bitmap = ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN) + val extras: Bundle = Bundle() + extras.putParcelable(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, coverBitmap) + extras.putParcelable(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, coverBitmap) + return MediaDescriptionCompat.Builder().apply { + setMediaId(station.uuid) + setIconBitmap(coverBitmap) + setTitle(station.name) + setSubtitle(station.name) // metadata + //setDescription(episode.podcastName) + setExtras(extras) + }.build() + } + + + + /* Creates a fallback station - stupid hack for Android Auto compatibility :-/ */ fun createFallbackStation(): Station { return Station(name = "KCSB", streamUris = mutableListOf("http://live.kcsb.org:80/KCSB_128"), streamContent = Keys.MIME_TYPE_MPEG) diff --git a/app/src/main/java/org/y20k/transistor/helpers/NotificationHelper.kt b/app/src/main/java/org/y20k/transistor/helpers/NotificationHelper.kt index 20f288bb..c56b1b06 100644 --- a/app/src/main/java/org/y20k/transistor/helpers/NotificationHelper.kt +++ b/app/src/main/java/org/y20k/transistor/helpers/NotificationHelper.kt @@ -14,132 +14,126 @@ package org.y20k.transistor.helpers -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context -import android.os.Build +import android.graphics.Bitmap +import android.net.Uri import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.media.session.MediaButtonReceiver +import com.google.android.exoplayer2.DefaultControlDispatcher +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.y20k.transistor.Keys -import org.y20k.transistor.PlayerService import org.y20k.transistor.R -import org.y20k.transistor.core.Station -import org.y20k.transistor.extensions.isFastForwardEnabled -import org.y20k.transistor.extensions.isPlayEnabled -import org.y20k.transistor.extensions.isPlaying -import org.y20k.transistor.extensions.isRewindEnabled /* * NotificationHelper class + * Credit: https://github.com/android/uamp/blob/5bae9316b60ba298b6080de1fcad53f6f74eb0bf/common/src/main/java/com/example/android/uamp/media/UampNotificationManager.kt */ -class NotificationHelper(private val playerService: PlayerService) { +class NotificationHelper(private val context: Context, sessionToken: MediaSessionCompat.Token, notificationListener: PlayerNotificationManager.NotificationListener) { /* Define log tag */ private val TAG: String = LogHelper.makeLogTag(NotificationHelper::class.java) /* Main class variables */ - private val notificationManager: NotificationManager = playerService.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - - /* Creates notification */ - fun buildNotification(sessionToken: MediaSessionCompat.Token, station: Station, metadataString: String): Notification { - if (shouldCreateNowPlayingChannel()) { - createNowPlayingChannel() + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Main + serviceJob) + private val notificationManager: PlayerNotificationManager + + + /* Constructor */ + init { + val mediaController = MediaControllerCompat(context, sessionToken) + notificationManager = PlayerNotificationManager.createWithNotificationChannel( + context, + Keys.NOW_PLAYING_NOTIFICATION_CHANNEL_ID, + R.string.notification_now_playing_channel_name, + R.string.notification_now_playing_channel_description, + Keys.NOW_PLAYING_NOTIFICATION_ID, + DescriptionAdapter(mediaController), + notificationListener + ).apply { + // note: notification icons are customized in values.xml + setMediaSessionToken(sessionToken) + setSmallIcon(R.drawable.ic_notification_app_icon_white_24dp) + setUsePlayPauseActions(true) + setControlDispatcher(DefaultControlDispatcher(0L, 0L)) // hide fastforward and reweind + setUseStopAction(false) // set true to display the dismiss button + setUsePreviousAction(false) + setUsePreviousActionInCompactView(false) + setUseNextAction(false) + setUseNextActionInCompactView(false) + setUseChronometer(true) } + } - val controller = MediaControllerCompat(playerService, sessionToken) - val playbackState = controller.playbackState - - val builder = NotificationCompat.Builder(playerService, Keys.NOTIFICATION_NOW_PLAYING_CHANNEL) - // add actions for rewind, play/pause, fast forward, based on what's enabled - var playPauseIndex = 0 - if (playbackState.isRewindEnabled) { - builder.addAction(rewindAction) - ++playPauseIndex - } - if (playbackState.isPlaying) { - builder.addAction(pauseAction) - } else if (playbackState.isPlayEnabled) { - builder.addAction(playAction) - } - if (playbackState.isFastForwardEnabled) { - builder.addAction(fastForwardAction) - } + /* Hides notification via notification manager */ + fun hideNotification() { + notificationManager.setPlayer(null) + } - val metadata: String - if (playbackState.isPlaying && metadataString.isNotEmpty()) { - metadata = metadataString - } else { - metadata = station.name - } - val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() - .setCancelButtonIntent(stopPendingIntent) - .setMediaSession(sessionToken) - .setShowActionsInCompactView(playPauseIndex) - .setShowCancelButton(true) - - return builder.setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setContentIntent(controller.sessionActivity) // todo check if sessionActivity is correct - .setContentTitle(station.name) - .setContentText(metadata) - .setDeleteIntent(stopPendingIntent) - .setLargeIcon(ImageHelper.getScaledStationImage(playerService, station.image, Keys.SIZE_COVER_NOTIFICATION_LARGE_ICON)) - .setSmallIcon(R.drawable.ic_notification_app_icon_white_24dp) - .setStyle(mediaStyle) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .build() + /* Displays notification via notification manager */ + fun showNotificationForPlayer(player: Player) { + notificationManager.setPlayer(player) } - /* Checks if notification channel should be created */ - private fun shouldCreateNowPlayingChannel() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !nowPlayingChannelExists() + /* Triggers notification */ + fun updateNotification() { + notificationManager.invalidate() + } - /* Checks if notification channel exists */ - @RequiresApi(Build.VERSION_CODES.O) - private fun nowPlayingChannelExists() = notificationManager.getNotificationChannel(Keys.NOTIFICATION_NOW_PLAYING_CHANNEL) != null + /* + * Inner class: Create content of notification from metaddata + */ + private inner class DescriptionAdapter(private val controller: MediaControllerCompat) : PlayerNotificationManager.MediaDescriptionAdapter { + var currentIconUri: Uri? = null + var currentBitmap: Bitmap? = null - /* Create a notification channel */ - @RequiresApi(Build.VERSION_CODES.O) - private fun createNowPlayingChannel() { - val notificationChannel = NotificationChannel(Keys.NOTIFICATION_NOW_PLAYING_CHANNEL, - playerService.getString(R.string.notification_now_playing_channel_name), - NotificationManager.IMPORTANCE_LOW) - .apply { - description = playerService.getString(R.string.notification_now_playing_channel_description) - } - notificationManager.createNotificationChannel(notificationChannel) - } + override fun createCurrentContentIntent(player: Player): PendingIntent? = controller.sessionActivity + + override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString() + override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString() - /* Notification actions */ - private val fastForwardAction = NotificationCompat.Action( - R.drawable.ic_notification_skip_to_next_36dp, - playerService.getString(R.string.notification_skip_to_next), - MediaButtonReceiver.buildMediaButtonPendingIntent(playerService, PlaybackStateCompat.ACTION_SKIP_TO_NEXT)) - private val playAction = NotificationCompat.Action( - R.drawable.ic_notification_play_36dp, - playerService.getString(R.string.notification_play), - MediaButtonReceiver.buildMediaButtonPendingIntent(playerService, PlaybackStateCompat.ACTION_PLAY)) - private val pauseAction = NotificationCompat.Action( - R.drawable.ic_notification_stop_36dp, - playerService.getString(R.string.notification_stop), - MediaButtonReceiver.buildMediaButtonPendingIntent(playerService, PlaybackStateCompat.ACTION_PAUSE)) - private val rewindAction = NotificationCompat.Action( - R.drawable.ic_notification_skip_to_previous_36dp, - playerService.getString(R.string.notification_skip_to_previous), - MediaButtonReceiver.buildMediaButtonPendingIntent(playerService, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)) - private val stopPendingIntent = - MediaButtonReceiver.buildMediaButtonPendingIntent(playerService, PlaybackStateCompat.ACTION_STOP) + override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? { + val iconUri: Uri? = controller.metadata.description.iconUri + return if (currentIconUri != iconUri || currentBitmap == null) { + // Cache the bitmap for the current song so that successive calls to + // `getCurrentLargeIcon` don't cause the bitmap to be recreated. + currentIconUri = iconUri + serviceScope.launch { + currentBitmap = iconUri?.let { + resolveUriAsBitmap(it) + } + currentBitmap?.let { callback.onBitmap(it) } + } + null + } else { + currentBitmap + } + } + private suspend fun resolveUriAsBitmap(currentIconUri: Uri): Bitmap { + return withContext(IO) { + // Block on downloading artwork. + ImageHelper.getStationImage(context, currentIconUri.toString()) + } + } + } + /* + * End of inner class + */ } diff --git a/app/src/main/java/org/y20k/transistor/playback/PlayerController.kt b/app/src/main/java/org/y20k/transistor/playback/PlayerController.kt new file mode 100644 index 00000000..32517294 --- /dev/null +++ b/app/src/main/java/org/y20k/transistor/playback/PlayerController.kt @@ -0,0 +1,106 @@ +/* + * PlayerController.kt + * Implements the PlayerController class + * PlayerController is provides playback controls for PlayerService + * + * This file is part of + * TRANSISTOR - Radio App for Android + * + * Copyright (c) 2015-21 - Y20K.org + * Licensed under the MIT-License + * http://opensource.org/licenses/MIT + */ + + +package org.y20k.transistor.playback + +import android.os.ResultReceiver +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.os.bundleOf +import org.y20k.transistor.Keys +import org.y20k.transistor.helpers.LogHelper + + +/* + * PlayerController class + */ +class PlayerController (private val mediaController: MediaControllerCompat) { + + /* Define log tag */ + private val TAG: String = LogHelper.makeLogTag(PlayerController::class.java) + + + /* Main class variables */ + val transportControls: MediaControllerCompat.TransportControls = mediaController.transportControls + + + /* Start playback for given media id */ + fun play(mediaId: String = String()) { + if (mediaId.isNotEmpty()) { + transportControls.playFromMediaId(mediaId, null) + } + } + + + /* Pause playback */ + fun pause() { + transportControls.pause() + } + + /* Skip back 10 seconds */ + fun skipBack() { + transportControls.skipToPrevious() + } + + /* Skip forward 30 seconds */ + fun skipForward(episodeDuration: Long) { + transportControls.skipToNext() + } + + + /* Seek to given position */ + fun seekTo(position: Long) { + transportControls.seekTo(position) + } + + + /* Send command to start sleep timer */ + fun startSleepTimer() { + mediaController.sendCommand(Keys.CMD_START_SLEEP_TIMER, null, null) + } + + + /* Send command to cancel sleep timer */ + fun cancelSleepTimer() { + mediaController.sendCommand(Keys.CMD_CANCEL_SLEEP_TIMER, null, null) + } + + + /* Send command to request updates - used to build the ui */ + fun requestProgressUpdate(resultReceiver: ResultReceiver) { + mediaController.sendCommand(Keys.CMD_REQUEST_PROGRESS_UPDATE, null, resultReceiver) + } + + + fun playStreamDirectly(streamUri: String) { + mediaController.sendCommand(Keys.CMD_PLAY_STREAM, bundleOf(Pair(Keys.KEY_STREAM_URI, streamUri)), null) + } + + + /* Register MediaController callback to get notified about player state changes */ + fun registerCallback(callback: MediaControllerCompat.Callback) { + mediaController.registerCallback(callback) + } + + + /* Unregister MediaController callback */ + fun unregisterCallback(callback: MediaControllerCompat.Callback) { + mediaController.unregisterCallback(callback) + } + + + /* Get the current playback state */ + fun getPlaybackState(): PlaybackStateCompat = mediaController.playbackState + +} diff --git a/app/src/main/java/org/y20k/transistor/PlayerService.kt b/app/src/main/java/org/y20k/transistor/playback/PlayerService.kt similarity index 61% rename from app/src/main/java/org/y20k/transistor/PlayerService.kt rename to app/src/main/java/org/y20k/transistor/playback/PlayerService.kt index b2154bc0..d0d664b5 100644 --- a/app/src/main/java/org/y20k/transistor/PlayerService.kt +++ b/app/src/main/java/org/y20k/transistor/playback/PlayerService.kt @@ -1,7 +1,7 @@ /* * PlayerService.kt * Implements the PlayerService class - * PlayerService is Transistor's foreground service that plays radio station streams and handles playback controls + * PlayerService is Transistor's foreground service that plays radio station audio * * This file is part of * TRANSISTOR - Radio App for Android @@ -12,35 +12,35 @@ */ -package org.y20k.transistor +package org.y20k.transistor.playback +import android.app.Notification import android.app.PendingIntent -import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.media.audiofx.AudioEffect import android.media.session.PlaybackState +import android.net.Uri import android.os.Bundle import android.os.CountDownTimer import android.os.ResultReceiver import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.widget.Toast -import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT -import androidx.media.session.MediaButtonReceiver import com.google.android.exoplayer2.* import com.google.android.exoplayer2.analytics.AnalyticsListener 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.metadata.Metadata import com.google.android.exoplayer2.metadata.MetadataOutput import com.google.android.exoplayer2.metadata.icy.IcyHeaders @@ -48,24 +48,25 @@ import com.google.android.exoplayer2.metadata.icy.IcyInfo import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.upstream.* import com.google.android.exoplayer2.util.Util import kotlinx.coroutines.* +import org.y20k.transistor.Keys +import org.y20k.transistor.R import org.y20k.transistor.collection.CollectionProvider import org.y20k.transistor.core.Collection import org.y20k.transistor.core.Station -import org.y20k.transistor.extensions.isActive import org.y20k.transistor.helpers.* import org.y20k.transistor.ui.PlayerState import java.util.* -import kotlin.coroutines.CoroutineContext import kotlin.math.min /* * PlayerService class */ -class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, MetadataOutput,CoroutineScope { +class PlayerService(): MediaBrowserServiceCompat(), MetadataOutput { /* Define log tag */ @@ -83,8 +84,7 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada private lateinit var backgroundJob: Job private lateinit var packageValidator: PackageValidator private lateinit var mediaSession: MediaSessionCompat - private lateinit var mediaController: MediaControllerCompat - private lateinit var notificationManager: NotificationManagerCompat + private lateinit var mediaSessionConnector: MediaSessionConnector private lateinit var notificationHelper: NotificationHelper private lateinit var userAgent: String private lateinit var modificationDate: Date @@ -94,10 +94,6 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada private var playbackRestartCounter: Int = 0 - /* Overrides coroutineContext variable */ - override val coroutineContext: CoroutineContext get() = backgroundJob + Dispatchers.Main - - /* Overrides onCreate from Service */ override fun onCreate() { super.onCreate() @@ -121,20 +117,36 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada metadataHistory = PreferencesHelper.loadMetadataHistory(this) // create player - player = createPlayer() + createPlayer() // create a new MediaSession - mediaSession = createMediaSession() - sessionToken = mediaSession.sessionToken - - // because ExoPlayer will manage the MediaSession, add the service as a callback for state changes - mediaController = MediaControllerCompat(this, mediaSession).also { - it.registerCallback(MediaControllerCallback()) - } + createMediaSession() + + // ExoPlayer manages MediaSession + mediaSessionConnector = MediaSessionConnector(mediaSession) + mediaSessionConnector.setPlaybackPreparer(preparer) + //mediaSessionConnector.setMediaButtonEventHandler(buttonEventHandler) + //mediaSessionConnector.setMediaMetadataProvider(metadataProvider) + mediaSessionConnector.setQueueNavigator(object : TimelineQueueNavigator(mediaSession) { + override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { + // create media description - used in notification + return CollectionHelper.buildStationMediaDescription(this@PlayerService, station) + } + override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) { + if (player.isPlaying) { player.pause() } + station = CollectionHelper.getPreviousStation(collection, station.uuid) + preparePlayer(true) + } + override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) { + if (player.isPlaying) { player.pause() } + station = CollectionHelper.getNextStation(collection, station.uuid) + preparePlayer(true) + } + }) - // initialize notification helper and notification manager - notificationHelper = NotificationHelper(this) - notificationManager = NotificationManagerCompat.from(this) + // initialize notification helper + notificationHelper = NotificationHelper(this, mediaSession.sessionToken, notificationListener) + notificationHelper.showNotificationForPlayer(player) // create and register collection changed receiver collectionChangedReceiver = createCollectionChangedReceiver() @@ -145,32 +157,31 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada } - /* Overrides onStartCommand from Service */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - - if (intent != null && intent.action == Keys.ACTION_STOP) { - stopPlayback() - } - - if (intent != null && intent.action == Keys.ACTION_START) { - if (intent.hasExtra(Keys.EXTRA_STATION_UUID)) { - val stationUuid: String = intent.getStringExtra(Keys.EXTRA_STATION_UUID) ?: String() - station = CollectionHelper.getStation(collection, stationUuid) - } else if(intent.hasExtra(Keys.EXTRA_STREAM_URI)) { - val streamUri: String = intent.getStringExtra(Keys.EXTRA_STREAM_URI) ?: String() - station = CollectionHelper.getStationWithStreamUri(collection, streamUri) - } else { - station = CollectionHelper.getStation(collection, playerState.stationUuid) - } - if (station.isValid()) { - startPlayback() - } - } - - MediaButtonReceiver.handleIntent(mediaSession, intent) - return Service.START_NOT_STICKY - } +// /* Overrides onStartCommand from Service */ +// override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { +// super.onStartCommand(intent, flags, startId) +// if (intent != null && intent.action == Keys.ACTION_STOP) { +// stopPlayback() +// } +// +// if (intent != null && intent.action == Keys.ACTION_START) { +// if (intent.hasExtra(Keys.EXTRA_STATION_UUID)) { +// val stationUuid: String = intent.getStringExtra(Keys.EXTRA_STATION_UUID) ?: String() +// station = CollectionHelper.getStation(collection, stationUuid) +// } else if(intent.hasExtra(Keys.EXTRA_STREAM_URI)) { +// val streamUri: String = intent.getStringExtra(Keys.EXTRA_STREAM_URI) ?: String() +// station = CollectionHelper.getStationWithStreamUri(collection, streamUri) +// } else { +// station = CollectionHelper.getStation(collection, playerState.stationUuid) +// } +// if (station.isValid()) { +// startPlayback() +// } +// } +// +// MediaButtonReceiver.handleIntent(mediaSession, intent) +// return Service.START_NOT_STICKY +// } /* Overrides onTaskRemoved from Service */ @@ -239,10 +250,8 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada metadataHistory.removeAt(0) } // update notification - mediaController.playbackState?.let { updateNotification(it) } - // update metadata - mediaSession.setMetadata(CollectionHelper.buildStationMediaMetadata(this@PlayerService, station, metadataHistory.last())) - // save history to + notificationHelper.updateNotification() + // save history PreferencesHelper.saveMetadataHistory(this, metadataHistory) } @@ -297,52 +306,6 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada } -// /* Overrides onPlayerError from Player.EventListener */ -// override fun onPlayerError(error: ExoPlaybackException) { -// when (error.type) { -// ExoPlaybackException.TYPE_SOURCE -> LogHelper.e(TAG, "ExoPlaybackException TYPE_SOURCE: " + error.sourceException.message) -// ExoPlaybackException.TYPE_RENDERER -> ftop(TAG, "ExoPlaybackException TYPE_RENDERER: " + error.rendererException.message) -// ExoPlaybackException.TYPE_UNEXPECTED -> LogHelper.e(TAG, "ExoPlaybackException TYPE_UNEXPECTED: " + error.unexpectedException.message) -// else -> LogHelper.e(TAG, "ExoPlaybackException OTHER: " + error.type) -// } -// } - - - /* Overrides onPlayerStateChanged from Player.EventListener */ - override fun onPlayerStateChanged(playWhenReady: Boolean, playerState: Int) { - when (playWhenReady) { - // CASE: playWhenReady = true - true -> { - if (playerState == Player.STATE_READY) { - // active playback: update media session and save state - handlePlaybackChange(PlaybackStateCompat.STATE_PLAYING) - } else if (playerState == Player.STATE_ENDED) { - // playback reached end: stop / end playback - handlePlaybackEnded() - } else { - // not playing because the player is buffering, stopped or failed - check playbackState and player.getPlaybackError for details - handlePlaybackChange(PlaybackStateCompat.STATE_BUFFERING) - } - } - // CASE: playWhenReady = false - false -> { - if (playerState == Player.STATE_READY) { - // stopped by app: update media session and save state - handlePlaybackChange(PlaybackStateCompat.STATE_PAUSED) - } else if (playerState == Player.STATE_ENDED) { - // ended by app: update media session and save state - handlePlaybackChange(PlaybackStateCompat.STATE_STOPPED) - } else { - LogHelper.w(TAG, "Unhandled player state change. Treat state as: playback stopped by app.") - handlePlaybackChange(PlaybackStateCompat.STATE_STOPPED) - } - // stop sleep timer - if running - cancelSleepTimer() - } - } - } - - /* Updates media session and save state */ private fun handlePlaybackChange(playbackState: Int) { // reset restart counter @@ -361,56 +324,55 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada // restart playback for up to five times if (playbackRestartCounter < 5) { playbackRestartCounter++ - startPlayback() + player.stop() + player.play() } else { - stopPlayback() + player.stop() Toast.makeText(this, this.getString(R.string.toastmessage_error_restart_playback_failed), Toast.LENGTH_LONG).show() } } /* Creates a new MediaSession */ - private fun createMediaSession(): MediaSessionCompat { - val initialPlaybackState: Int = PreferencesHelper.loadPlayerPlaybackState(this) - val sessionActivityPendingIntent = - packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> - PendingIntent.getActivity(this, 0, sessionIntent, 0) - } - return MediaSessionCompat(this, TAG) - .apply { - setSessionActivity(sessionActivityPendingIntent) - setCallback(mediaSessionCallback) - setPlaybackState(createPlaybackState(initialPlaybackState, 0)) - } + private fun createMediaSession() { + val sessionActivityPendingIntent = packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> + PendingIntent.getActivity(this, 0, sessionIntent, 0) + } + mediaSession = MediaSessionCompat(this, TAG).apply { + setSessionActivity(sessionActivityPendingIntent) + isActive = true + } + sessionToken = mediaSession.sessionToken } /* Creates a simple exo player */ - private fun createPlayer(): SimpleExoPlayer { - if (this::player.isInitialized) { - player.removeAnalyticsListener(analyticsListener) - player.release() + private fun createPlayer() { + if (!this::player.isInitialized) { + val audioAttributes: AudioAttributes = AudioAttributes.Builder() + .setContentType(C.CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + player = SimpleExoPlayer.Builder(this) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setAudioAttributes(audioAttributes, true) + .setHandleAudioBecomingNoisy(true) + // player.setMediaSourceFactory() does not make sense here, since Transistor selects MediaSourceFactory based on stream type + .build() + player.addListener(playerListener) + player.addMetadataOutput(this) + player.addAnalyticsListener(analyticsListener) } - val audioAttributes: AudioAttributes = AudioAttributes.Builder() - .setContentType(C.CONTENT_TYPE_MUSIC) - .setUsage(C.USAGE_MEDIA) - .build() - val player = SimpleExoPlayer.Builder(this) - .setWakeMode(C.WAKE_MODE_NETWORK) - .setAudioAttributes(audioAttributes, true) - .setHandleAudioBecomingNoisy(true) - // player.setMediaSourceFactory() does not make sense here, since Transistor selects MediaSourceFactory based on stream type - .build() - player.addListener(this@PlayerService) - player.addAnalyticsListener(analyticsListener) - player.addMetadataOutput(this) - return player } /* Prepares player with media source created from current station */ - private fun preparePlayer() { - // todo only prepare if not already prepared + private fun preparePlayer(playWhenReady: Boolean) { + // sanity check + if (!station.isValid()) { + LogHelper.e(TAG, "Unable to start playback. No radio station has been loaded.") + return + } // build media item. val mediaItem: MediaItem = MediaItem.fromUri(station.getStreamUri()) @@ -433,6 +395,12 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada player.setMediaSource(mediaSource) // player.setMediaItem() - unable to use here, because different media items may need different MediaSourceFactories to work properly player.prepare() + + // update media session connector + mediaSessionConnector.setPlayer(player) + + // set playWhenReady state + player.playWhenReady = playWhenReady } @@ -455,38 +423,6 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada } - /* Start playback with current station */ - private fun startPlayback() { - LogHelper.d(TAG, "Starting Playback. Station: ${station.name}.") - // check if station is valid and collection has stations - if (!station.isValid() && collection.stations.isNullOrEmpty()) { - LogHelper.e(TAG, "Unable to start playback. Station has no stream addresses.") - return - } - // default to last played station, if no station has been selected - if (!station.isValid() && collection.stations.isNotEmpty()) { - LogHelper.w(TAG, "No station has been selected. Starting playback of last played station.") - station = CollectionHelper.getStation(collection, PreferencesHelper.loadLastPlayedStation(this@PlayerService)) - } - // update metadata and prepare player - updateMetadata(station.name) - preparePlayer() - // start playback - player.playWhenReady = true - } - - - /* Stop playback */ - private fun stopPlayback() { - LogHelper.d(TAG, "Stopping Playback") - if (!player.isPlaying) { - handlePlaybackChange(PlaybackStateCompat.STATE_STOPPED) - } - // pauses playback - player.playWhenReady = false - } - - /* Creates playback state - actions for playback state to be used in media session callback */ private fun createPlaybackState(state: Int, position: Long): PlaybackStateCompat { val skipActions: Long = PlaybackStateCompat.ACTION_FAST_FORWARD or PlaybackStateCompat.ACTION_REWIND or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS @@ -526,7 +462,7 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada // reset time remaining sleepTimerTimeRemaining = 0L // stop playback - stopPlayback() + player.stop() } override fun onTick(millisUntilFinished: Long) { sleepTimerTimeRemaining = millisUntilFinished @@ -546,16 +482,6 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada } - /* Sets playback speed */ - private fun setPlaybackSpeed(speed: Float = 1f) { - // update playback parameters - speed up playback - player.setPlaybackParameters(PlaybackParameters(speed)) - // save speed - // playerState.playbackSpeed = speed - PreferencesHelper.savePlayerPlaybackSpeed(this, speed) - } - - /* Loads media items into result - assumes that collectionProvider is initialized */ private fun loadChildren(parentId: String, result: Result>) { val mediaItems = ArrayList() @@ -602,7 +528,7 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada /* Reads collection of stations from storage using GSON */ private fun loadCollection(context: Context) { LogHelper.v(TAG, "Loading collection of stations from storage") - launch { + CoroutineScope(Dispatchers.Main).launch { // load collection on background thread val deferred: Deferred = async(Dispatchers.Default) { FileHelper.readCollectionSuspended(context) } // wait for result and update collection @@ -625,116 +551,120 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada PreferencesHelper.savePlayerState(this, playerState) } - - /* Updates notification */ - private fun updateNotification(state: PlaybackStateCompat) { - // skip building a notification when state is "none" and metadata is null - // val notification = if (mediaController.metadata != null && state.state != PlaybackStateCompat.STATE_NONE) { - val notification = if (state.state != PlaybackStateCompat.STATE_NONE) { - val metadataString: String = if(metadataHistory.isNotEmpty()) metadataHistory.last() else String() - notificationHelper.buildNotification(mediaSession.sessionToken, station, metadataString) - } else { - null + /* + * EventListener: Listener for ExoPlayer Events + */ + private val playerListener = object : Player.EventListener { + override fun onIsPlayingChanged(isPlaying: Boolean){ + if (isPlaying) { + // active playback + handlePlaybackChange(PlaybackStateCompat.STATE_PLAYING) + } else { + // handled in onPlayWhenReadyChanged + } } - when (state.isActive) { - // CASE: Playback has started - true -> { - /** - * This may look strange, but the documentation for [Service.startForeground] - * notes that "calling this method does *not* put the service in the started - * state itself, even though the name sounds like it." - */ - if (notification != null) { - notificationManager.notify(Keys.NOTIFICATION_NOW_PLAYING_ID, notification) - if (!isForegroundService) { - ContextCompat.startForegroundService(applicationContext, Intent(applicationContext, this@PlayerService.javaClass)) - startForeground(Keys.NOTIFICATION_NOW_PLAYING_ID, notification) - isForegroundService = true - } - } - } - // CASE: Playback has stopped - false -> { - if (isForegroundService) { - stopForeground(false) - isForegroundService = false - - // if playback has ended, also stop the service. - if (state.state == PlaybackStateCompat.STATE_NONE) { - stopSelf() + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + if (!playWhenReady) { + when (reason) { + Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM -> { + // playback reached end: stop / end playback + handlePlaybackChange(PlaybackStateCompat.STATE_STOPPED) } - - if (notification != null && state.state != PlaybackStateCompat.STATE_STOPPED) { - notificationManager.notify(Keys.NOTIFICATION_NOW_PLAYING_ID, notification) - } else { - // remove notification - playback ended (or buildNotification failed) - stopForeground(true) + else -> { + // playback has been paused by user or OS: update media session and save state + // PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST or + // PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS or + // PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY or + // PLAY_WHEN_READY_CHANGE_REASON_REMOTE + handlePlaybackChange(PlaybackStateCompat.STATE_STOPPED) } } - } } } + /* + * End of declaration + */ + /* - * Custom AnalyticsListener that enables AudioFX equalizer integration + * NotificationListener: handles foreground state of service */ - private var analyticsListener = object: AnalyticsListener { - override fun onAudioSessionId(eventTime: AnalyticsListener.EventTime, audioSessionId: Int) { - super.onAudioSessionId(eventTime, audioSessionId) - // integrate with system equalizer (AudioFX) - val intent: Intent = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) - intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) - intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) - sendBroadcast(intent) + private val notificationListener = object : PlayerNotificationManager.NotificationListener { + override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { + super.onNotificationCancelled(notificationId, dismissedByUser) + stopForeground(true) + isForegroundService = false + stopSelf() + } + + override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { + super.onNotificationPosted(notificationId, notification, ongoing) + if (ongoing && !isForegroundService) { + ContextCompat.startForegroundService(applicationContext, Intent(applicationContext, this@PlayerService.javaClass)) + startForeground(Keys.NOW_PLAYING_NOTIFICATION_ID, notification) + isForegroundService = true + } } } + /* + * End of declaration + */ + + +// /* +// * MediaMetadataProvider: creates metadata for currently playing station +// */ +// private val metadataProvider = object : MediaSessionConnector.MediaMetadataProvider { +// override fun getMetadata(player: Player): MediaMetadataCompat { +// return CollectionHelper.buildStationMediaMetadata(this@PlayerService, station, metadataHistory.last()) +// } +// } +// /* +// * End of declaration +// */ /* - * Callback: Defines callbacks for active media session + * PlaybackPreparer: Handles prepare and play requests - as well as custom commands like sleep timer control */ - private var mediaSessionCallback = object: MediaSessionCompat.Callback() { - override fun onPlay() { - // stop current playback, if necessary - if (playerState.playbackState == PlaybackStateCompat.STATE_PLAYING) { - stopPlayback() - } - // start playback of current station - startPlayback() - } + private val preparer = object : MediaSessionConnector.PlaybackPreparer { - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - // get station, set metadata and start playback - station = CollectionHelper.getStation(collection, mediaId ?: String()) + override fun getSupportedPrepareActions(): Long = + PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH - startPlayback() - } + override fun onPrepare(playWhenReady: Boolean) = Unit + override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit - override fun onPause() { - stopPlayback() + override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { + // stop playback if necessary + if (player.isPlaying) { player.pause() } + // get station and start playback + station = CollectionHelper.getStation(collection, mediaId ?: String()) + preparePlayer(playWhenReady) } - override fun onStop() { - stopPlayback() - } + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { - override fun onPlayFromSearch(query: String?, extras: Bundle?) { // SPECIAL CASE: Empty query - user provided generic string e.g. 'Play music' - if (query.isNullOrEmpty()) { - // try to get last played station - station = CollectionHelper.getStation(collection, PreferencesHelper.loadLastPlayedStation(this@PlayerService)) - if (station.isValid()) { - startPlayback() + if (query.isEmpty()) { + // try to get newest episode + val stationMediaItem: MediaBrowserCompat.MediaItem? = collectionProvider.getFirstStation() + if (stationMediaItem != null) { + onPrepareFromMediaId(stationMediaItem.mediaId!!, playWhenReady = true, extras = null) } else { // unable to get the first station - notify user Toast.makeText(this@PlayerService, R.string.toastmessage_error_no_station_found, Toast.LENGTH_LONG).show() LogHelper.e(TAG, "Unable to start playback. Please add a radio station first. (Collection size = ${collection.stations.size} | provider initialized = ${collectionProvider.isInitialized()})") } } - // NORMAL CASE: Try to match station name and voice query + // NORMAL CASE: Try to match podcast name and voice query else { val queryLowercase: String = query.toLowerCase(Locale.getDefault()) collectionProvider.stationListByName.forEach { mediaItem -> @@ -742,8 +672,8 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada val stationName: String = mediaItem.description.title.toString().toLowerCase(Locale.getDefault()) // FIRST: try to match the whole query if (stationName == queryLowercase) { - // start playback - onPlayFromMediaId(mediaItem.description.mediaId, null) + // start playback of newest podcast episode + onPrepareFromMediaId(mediaItem.description.mediaId!!, playWhenReady = true, extras = null) return } // SECOND: try to match parts of the query @@ -752,7 +682,7 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada words.forEach { word -> if (stationName.contains(word)) { // start playback - onPlayFromMediaId(mediaItem.description.mediaId, null) + onPrepareFromMediaId(mediaItem.description.mediaId!!, playWhenReady = true, extras = null) return } } @@ -764,88 +694,69 @@ class PlayerService(): MediaBrowserServiceCompat(), Player.EventListener, Metada } } - - override fun onFastForward() { - LogHelper.d(TAG, "onFastForward") - } - - override fun onRewind() { - LogHelper.d(TAG, "onRewind") - } - - override fun onSkipToPrevious() { - LogHelper.d(TAG, "onSkipToPrevious") - // stop current playback, if necessary - if (playerState.playbackState == PlaybackStateCompat.STATE_PLAYING) { - stopPlayback() - } - // get station, set metadata and start playback - station = CollectionHelper.getPreviousStation(collection, station.uuid) - - startPlayback() - } - - override fun onSkipToNext() { - LogHelper.d(TAG, "onSkipToNext") - // stop current playback, if necessary - if (playerState.playbackState == PlaybackStateCompat.STATE_PLAYING) { - stopPlayback() - } - // get station, set metadata and start playback - station = CollectionHelper.getNextStation(collection, station.uuid) - - startPlayback() - } - - override fun onSeekTo(posistion: Long) { - LogHelper.d(TAG, "onSeekTo") - } - - override fun onCommand(command: String?, extras: Bundle?, cb: ResultReceiver?) { + override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { when (command) { - Keys.CMD_PLAY_STREAM -> { - val streamUri: String = extras?.getString(Keys.KEY_STREAM_URI) ?: String() - station = CollectionHelper.getStationWithStreamUri(collection, streamUri) - startPlayback() - } Keys.CMD_RELOAD_PLAYER_STATE -> { playerState = PreferencesHelper.loadPlayerState(this@PlayerService) + return true } Keys.CMD_REQUEST_PROGRESS_UPDATE -> { if (cb != null) { - val playbackProgressBundle: Bundle = bundleOf(Keys.RESULT_DATA_METADATA to metadataHistory) - if (sleepTimerTimeRemaining > 0L) { - playbackProgressBundle.putLong(Keys.RESULT_DATA_SLEEP_TIMER_REMAINING, sleepTimerTimeRemaining) + // check if episode has been prepared - assumes that then the player has been prepared as well + if (station.isValid()) { + val playbackProgressBundle: Bundle = bundleOf(Keys.RESULT_DATA_METADATA to metadataHistory) + if (sleepTimerTimeRemaining > 0L) { + playbackProgressBundle.putLong(Keys.RESULT_DATA_SLEEP_TIMER_REMAINING, sleepTimerTimeRemaining) + } + cb.send(Keys.RESULT_CODE_PERIODIC_PROGRESS_UPDATE, playbackProgressBundle) + return true + } else { + return false } - cb.send(Keys.RESULT_CODE_PERIODIC_PROGRESS_UPDATE, playbackProgressBundle) + } else { + return false } } Keys.CMD_START_SLEEP_TIMER -> { startSleepTimer() + return true } Keys.CMD_CANCEL_SLEEP_TIMER -> { cancelSleepTimer() + return true + } + Keys.CMD_PLAY_STREAM -> { + // stop playback if necessary + if (player.isPlaying) { player.pause() } + // get station and start playback + val streamUri: String = extras?.getString(Keys.KEY_STREAM_URI) ?: String() + station = CollectionHelper.getStationWithStreamUri(collection, streamUri) + preparePlayer(true) + return true + } + + else -> { + return false } } } - } /* - * End of callback + * End of declaration */ /* - * Inner class: Class to receive callbacks about state changes to the MediaSessionCompat - handles notification - * Source: https://github.com/googlesamples/android-UniversalMusicPlayer/blob/master/common/src/main/java/com/example/android/uamp/media/MusicService.kt + * Custom AnalyticsListener that enables AudioFX equalizer integration */ - private inner class MediaControllerCallback : MediaControllerCompat.Callback() { - override fun onMetadataChanged(metadata: MediaMetadataCompat?) { - mediaController.playbackState?.let { updateNotification(it) } - } - - override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { - state?.let { updateNotification(it) } + private var analyticsListener = object: AnalyticsListener { + override fun onAudioSessionIdChanged(eventTime: AnalyticsListener.EventTime, audioSessionId: Int) { + super.onAudioSessionIdChanged(eventTime, audioSessionId) + // integrate with system equalizer (AudioFX) + val intent: Intent = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) + intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) + intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + sendBroadcast(intent) } } diff --git a/app/src/main/res/drawable/ic_notification_clear_36dp.xml b/app/src/main/res/drawable/ic_notification_clear_36dp.xml new file mode 100644 index 00000000..fb30558c --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_clear_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml new file mode 100644 index 00000000..c2024eba --- /dev/null +++ b/app/src/main/res/values/values.xml @@ -0,0 +1,6 @@ + + + @drawable/ic_notification_play_36dp + @drawable/ic_notification_stop_36dp + @drawable/ic_notification_clear_36dp + diff --git a/app/src/main/res/xml/allowed_media_browser_callers.xml b/app/src/main/res/xml/allowed_media_browser_callers.xml index 52d23b80..97f8a5ef 100644 --- a/app/src/main/res/xml/allowed_media_browser_callers.xml +++ b/app/src/main/res/xml/allowed_media_browser_callers.xml @@ -89,4 +89,4 @@ Adding New Keys: - \ No newline at end of file + diff --git a/build.gradle b/build.gradle index dec757ae..80e36164 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlinVersion = '1.4.21' + ext.kotlinVersion = '1.4.30' repositories { google() @@ -9,7 +9,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.1.1' + classpath 'com.android.tools.build:gradle:4.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/metadata/en-US/changelogs/82.txt b/metadata/en-US/changelogs/82.txt new file mode 100644 index 00000000..ab496327 --- /dev/null +++ b/metadata/en-US/changelogs/82.txt @@ -0,0 +1,6 @@ +# v4.0.11 - Andy Warhol + +**2021-02-25** + +- Esperanto language version +- updated translations