Add media controls to notification

Add media controls to the Playback Notification [Loop, Last, Play/Pause, Next, Shuffle]
This commit is contained in:
OxygenCobalt 2020-11-01 11:06:26 -07:00
parent 09971afb42
commit 08bd0ece3a
20 changed files with 265 additions and 79 deletions

View file

@ -15,6 +15,20 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
// Get a bitmap for a song, onDone will be called when the bitmap is loaded.
// Don't use this on UI elements, thats what the BindingAdapters are for.
fun getBitmap(song: Song, context: Context, onDone: (Bitmap) -> Unit) {
Coil.enqueue(
ImageRequest.Builder(context)
.data(song.album.coverUri)
.error(R.drawable.ic_song)
.target { onDone(it.toBitmap()) }
.build()
)
}
// --- BINDING ADAPTERS ---
// Get the cover art for a song
@BindingAdapter("coverArt")
fun ImageView.bindCoverArt(song: Song) {
@ -131,17 +145,6 @@ fun ImageView.bindGenreImage(genre: Genre) {
Coil.imageLoader(context).enqueue(request)
}
// Get a bitmap for a song, onDone will be called when the bitmap is loaded.
fun getBitmap(song: Song, context: Context, onDone: (Bitmap) -> Unit) {
Coil.enqueue(
ImageRequest.Builder(context)
.data(song.album.coverUri)
.error(R.drawable.ic_song)
.target { onDone(it.toBitmap()) }
.build()
)
}
// Get the base request used across the other functions.
private fun getDefaultRequest(context: Context, imageView: ImageView): ImageRequest.Builder {
return ImageRequest.Builder(context)

View file

@ -25,8 +25,6 @@ class CompactPlaybackFragment : Fragment() {
): View? {
val binding = FragmentCompactPlaybackBinding.inflate(inflater)
// FIXME: Prevent the play/pause icon from animating on startup
// [requires new callback from PlaybackStateManager]
val iconPauseToPlay = ContextCompat.getDrawable(
requireContext(), R.drawable.ic_pause_to_play
) as AnimatedVectorDrawable
@ -62,21 +60,13 @@ class CompactPlaybackFragment : Fragment() {
}
playbackModel.isPlaying.observe(viewLifecycleOwner) {
if (true) {
if (it) {
// Animate the icon transition when the playing status switches
binding.playbackControls.setImageDrawable(iconPauseToPlay)
iconPauseToPlay.start()
} else {
binding.playbackControls.setImageDrawable(iconPlayToPause)
iconPlayToPause.start()
}
if (it) {
// Animate the icon transition when the playing status switches
binding.playbackControls.setImageDrawable(iconPauseToPlay)
iconPauseToPlay.start()
} else {
if (it) {
binding.playbackControls.setImageResource(R.drawable.ic_pause)
} else {
binding.playbackControls.setImageResource(R.drawable.ic_play)
}
binding.playbackControls.setImageDrawable(iconPlayToPause)
iconPlayToPause.start()
}
}
@ -88,10 +78,4 @@ class CompactPlaybackFragment : Fragment() {
return binding.root
}
override fun onPause() {
super.onPause()
// playbackModel.resetAnimStatus()
}
}

View file

@ -128,15 +128,15 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
when (it) {
LoopMode.NONE -> {
binding.playbackLoop.imageTintList = controlColor
binding.playbackLoop.setImageResource(R.drawable.ic_loop)
binding.playbackLoop.setImageResource(R.drawable.ic_loop_large)
}
LoopMode.ONCE -> {
binding.playbackLoop.imageTintList = accentColor
binding.playbackLoop.setImageResource(R.drawable.ic_loop_one)
binding.playbackLoop.setImageResource(R.drawable.ic_loop_one_large)
}
LoopMode.INFINITE -> {
binding.playbackLoop.imageTintList = accentColor
binding.playbackLoop.setImageResource(R.drawable.ic_loop)
binding.playbackLoop.setImageResource(R.drawable.ic_loop_large)
}
else -> return@observe

View file

@ -1,29 +1,39 @@
package org.oxycblt.auxio.playback
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.coil.getBitmap
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager
// Manager for the playback notification
// TODO: Implement some ability
internal class PlaybackNotificationHolder {
// Holder for the playback notification, should only be used by PlaybackService.
// TODO: You really need to rewrite this class. Christ.
// TODO: Disable skip prev/next buttons when you cant do those actions
// TODO: Implement a way to exit the notification
class PlaybackNotificationHolder {
private lateinit var mNotification: Notification
private lateinit var notificationManager: NotificationManager
private lateinit var baseNotification: NotificationCompat.Builder
private val playbackManager = PlaybackStateManager.getInstance()
private var isForeground = false
fun init(context: Context, session: MediaSessionCompat) {
fun init(context: Context, session: MediaSessionCompat, playbackService: PlaybackService) {
// Never run if the notification has already been created
if (!::mNotification.isInitialized) {
notificationManager =
@ -41,10 +51,20 @@ internal class PlaybackNotificationHolder {
baseNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_song)
.setStyle(MediaStyle().setMediaSession(session.sessionToken))
.setStyle(
MediaStyle()
.setMediaSession(session.sessionToken)
.setShowActionsInCompactView(1, 2, 3)
)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setChannelId(CHANNEL_ID)
.setShowWhen(false)
.setTicker(playbackService.getString(R.string.title_playback))
.addAction(createAction(ACTION_LOOP, playbackService))
.addAction(createAction(ACTION_SKIP_PREV, playbackService))
.addAction(createAction(ACTION_PLAY_PAUSE, playbackService))
.addAction(createAction(ACTION_SKIP_NEXT, playbackService))
.addAction(createAction(ACTION_SHUFFLE, playbackService))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
mNotification = baseNotification.build()
@ -67,23 +87,34 @@ internal class PlaybackNotificationHolder {
getBitmap(song, playbackService) {
baseNotification.setLargeIcon(it)
mNotification = baseNotification.build()
if (!isForeground) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
playbackService.startForeground(
NOTIFICATION_ID, mNotification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
playbackService.startForeground(NOTIFICATION_ID, mNotification)
}
} else {
notificationManager.notify(NOTIFICATION_ID, mNotification)
}
startForegroundOrNotify(playbackService)
}
}
@SuppressLint("RestrictedApi")
fun updatePlaying(playbackService: PlaybackService) {
baseNotification.mActions[2] = createAction(ACTION_PLAY_PAUSE, playbackService)
Log.d(this::class.simpleName, baseNotification.mActions[1].iconCompat?.resId.toString())
startForegroundOrNotify(playbackService)
}
@SuppressLint("RestrictedApi")
fun updateLoop(playbackService: PlaybackService) {
baseNotification.mActions[0] = createAction(ACTION_LOOP, playbackService)
startForegroundOrNotify(playbackService)
}
@SuppressLint("RestrictedApi")
fun updateShuffle(playbackService: PlaybackService) {
baseNotification.mActions[4] = createAction(ACTION_SHUFFLE, playbackService)
startForegroundOrNotify(playbackService)
}
fun stop(playbackService: PlaybackService) {
playbackService.stopForeground(true)
notificationManager.cancel(NOTIFICATION_ID)
@ -91,8 +122,87 @@ internal class PlaybackNotificationHolder {
isForeground = false
}
private fun createAction(action: String, playbackService: PlaybackService): NotificationCompat.Action {
val drawable = when (action) {
ACTION_LOOP -> {
when (playbackManager.loopMode) {
LoopMode.NONE -> R.drawable.ic_loop_disabled
LoopMode.ONCE -> R.drawable.ic_loop_one
LoopMode.INFINITE -> R.drawable.ic_loop
}
}
ACTION_SKIP_PREV -> {
R.drawable.ic_skip_prev
}
ACTION_PLAY_PAUSE -> {
if (playbackManager.isPlaying) {
R.drawable.ic_pause
} else {
R.drawable.ic_play
}
}
ACTION_SKIP_NEXT -> {
R.drawable.ic_skip_next
}
ACTION_SHUFFLE -> {
if (playbackManager.isShuffling) {
R.drawable.ic_shuffle
} else {
R.drawable.ic_shuffle_disabled
}
}
else -> R.drawable.ic_play
}
return NotificationCompat.Action.Builder(
drawable, action, createPlaybackAction(action, playbackService)
).build()
}
private fun createPlaybackAction(action: String, playbackService: PlaybackService): PendingIntent {
val intent = Intent()
intent.action = action
return PendingIntent.getBroadcast(
playbackService,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
private fun startForegroundOrNotify(playbackService: PlaybackService) {
mNotification = baseNotification.build()
// Start the service in the foreground if haven't already.
if (!isForeground) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
playbackService.startForeground(
NOTIFICATION_ID, mNotification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
playbackService.startForeground(NOTIFICATION_ID, mNotification)
}
} else {
notificationManager.notify(NOTIFICATION_ID, mNotification)
}
}
companion object {
const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK"
const val NOTIFICATION_ID = 0xA0A0
const val REQUEST_CODE = 0xA0C0
const val ACTION_LOOP = "ACTION_AUXIO_LOOP"
const val ACTION_SKIP_PREV = "ACTION_AUXIO_SKIP_PREV"
const val ACTION_PLAY_PAUSE = "ACTION_AUXIO_PLAY_PAUSE"
const val ACTION_SKIP_NEXT = "ACTION_AUXIO_SKIP_NEXT"
const val ACTION_SHUFFLE = "ACTION_AUXIO_SHUFFLE"
}
}

View file

@ -103,6 +103,11 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
// Set up callback for system events
systemReceiver = SystemEventReceiver()
IntentFilter().apply {
addAction(PlaybackNotificationHolder.ACTION_LOOP)
addAction(PlaybackNotificationHolder.ACTION_SKIP_PREV)
addAction(PlaybackNotificationHolder.ACTION_PLAY_PAUSE)
addAction(PlaybackNotificationHolder.ACTION_SKIP_NEXT)
addAction(PlaybackNotificationHolder.ACTION_SHUFFLE)
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
@ -115,7 +120,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
notificationHolder = PlaybackNotificationHolder()
notificationHolder.init(applicationContext, mediaSession)
notificationHolder.init(applicationContext, mediaSession, this)
// --- PLAYBACKSTATEMANAGER SETUP ---
@ -186,7 +191,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
// --- PLAYBACK STATE CALLBACK OVERRIDES ---
override fun onSongUpdate(song: Song?) {
song?.let {
val item = MediaItem.fromUri(it.id.toURI())
@ -210,13 +214,20 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
if (isPlaying && !player.isPlaying) {
player.play()
notificationHolder.updatePlaying(this)
startPollingPosition()
} else {
player.pause()
notificationHolder.updatePlaying(this)
}
}
override fun onShuffleUpdate(isShuffling: Boolean) {
changeIsFromAudioFocus = false
notificationHolder.updateShuffle(this)
}
override fun onLoopUpdate(mode: LoopMode) {
changeIsFromAudioFocus = false
@ -228,6 +239,8 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
player.repeatMode = Player.REPEAT_MODE_ONE
}
}
notificationHolder.updateLoop(this)
}
override fun onSeekConfirm(position: Long) {
@ -331,6 +344,15 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
action?.let {
when (it) {
PlaybackNotificationHolder.ACTION_LOOP ->
playbackManager.setLoopMode(playbackManager.loopMode.increment())
PlaybackNotificationHolder.ACTION_SKIP_PREV -> playbackManager.prev()
PlaybackNotificationHolder.ACTION_PLAY_PAUSE ->
playbackManager.setPlayingStatus(!playbackManager.isPlaying)
PlaybackNotificationHolder.ACTION_SKIP_NEXT -> playbackManager.next()
PlaybackNotificationHolder.ACTION_SHUFFLE ->
playbackManager.setShuffleStatus(!playbackManager.isShuffling)
BluetoothDevice.ACTION_ACL_CONNECTED -> resume()
BluetoothDevice.ACTION_ACL_DISCONNECTED -> pause()

View file

@ -16,9 +16,8 @@ import org.oxycblt.auxio.playback.state.PlaybackStateCallback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
// A ViewModel that acts as an intermediary between the UI and PlaybackStateManager
// TODO: Implement Looping Modes
// TODO: Implement User Queue
// TODO: Implement Persistence through Bundles/Databases/Idk
// TODO: Implement Persistence through a Database
class PlaybackViewModel : ViewModel(), PlaybackStateCallback {
// Playback
private val mSong = MutableLiveData<Song?>()

View file

@ -16,7 +16,7 @@ import kotlin.random.Random
// If you want to add ui-side things, add to PlaybackViewModel.
// [Yes, I know MediaSessionCompat exists, but I like having full control over the
// playback state instead of dealing with android's likely buggy code.]
internal class PlaybackStateManager {
class PlaybackStateManager private constructor() {
// Playback
private var mSong: Song? = null
set(value) {

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#80ffffff"
android:pathData="M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</vector>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z"/>
</vector>

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="m 7.5,6 h 3 v 12 h -3 z m 9,0 h -3 v 12 h 3 z" />
<path
android:fillColor="@android:color/white"
android:pathData="M 5.571429,19.5 H 9.857143 V 4.5000003 H 5.571429 Z M 14.142857,4.5000003 V 19.5 h 4.285714 V 4.5000003 Z"/>
</vector>

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8.25,6L8.25,12L18.75,12L18.75,12ZM8.25,18L8.25,12L18.75,12L18.75,12Z" />
android:pathData="M 5.0778755,4.0890012 V 12 H 18.922123 v 0 z m 0,15.8219978 V 12 H 18.922123 v 0 z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#80ffffff"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z" />
</vector>

View file

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
</vector>

View file

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z" />
</vector>

View file

@ -167,7 +167,7 @@
android:layout_marginStart="@dimen/margin_mid_large"
android:contentDescription="@string/description_skip_next"
android:background="@drawable/ui_unbounded_ripple"
android:src="@drawable/ic_skip_next"
android:src="@drawable/ic_skip_next_large"
android:onClick="@{() -> playbackModel.skipNext()}"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintStart_toEndOf="@+id/playback_play_pause"
@ -178,7 +178,7 @@
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="@dimen/size_play_pause_compact"
android:layout_height="@dimen/size_play_pause_compact"
android:src="@drawable/ic_skip_prev"
android:src="@drawable/ic_skip_prev_large"
android:contentDescription="@string/description_skip_prev"
android:background="@drawable/ui_unbounded_ripple"
android:layout_marginEnd="@dimen/margin_mid_large"
@ -207,7 +207,7 @@
android:layout_width="@dimen/size_play_pause_compact"
android:layout_height="@dimen/size_play_pause_compact"
android:background="@drawable/ui_unbounded_ripple"
android:src="@drawable/ic_loop"
android:src="@drawable/ic_loop_large"
android:layout_marginStart="@dimen/margin_mid_large"
android:onClick="@{() -> playbackModel.incrementLoopStatus()}"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"