From 08bd0ece3ae3f2571391ad8e3542e8cedd3dc481 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sun, 1 Nov 2020 11:06:26 -0700 Subject: [PATCH] Add media controls to notification Add media controls to the Playback Notification [Loop, Last, Play/Pause, Next, Shuffle] --- .../org/oxycblt/auxio/music/coil/CoilUtils.kt | 25 +-- .../auxio/playback/CompactPlaybackFragment.kt | 28 +--- .../auxio/playback/PlaybackFragment.kt | 6 +- .../playback/PlaybackNotificationHolder.kt | 146 +++++++++++++++--- .../oxycblt/auxio/playback/PlaybackService.kt | 28 +++- .../auxio/playback/PlaybackViewModel.kt | 3 +- .../playback/state/PlaybackStateManager.kt | 2 +- app/src/main/res/drawable/ic_loop.xml | 4 +- .../main/res/drawable/ic_loop_disabled.xml | 10 ++ app/src/main/res/drawable/ic_loop_large.xml | 11 ++ app/src/main/res/drawable/ic_loop_one.xml | 4 +- .../main/res/drawable/ic_loop_one_large.xml | 11 ++ app/src/main/res/drawable/ic_pause.xml | 10 +- app/src/main/res/drawable/ic_play.xml | 6 +- .../main/res/drawable/ic_shuffle_disabled.xml | 10 ++ app/src/main/res/drawable/ic_skip_next.xml | 5 +- .../main/res/drawable/ic_skip_next_large.xml | 12 ++ app/src/main/res/drawable/ic_skip_prev.xml | 5 +- .../main/res/drawable/ic_skip_prev_large.xml | 12 ++ app/src/main/res/layout/fragment_playback.xml | 6 +- 20 files changed, 265 insertions(+), 79 deletions(-) create mode 100644 app/src/main/res/drawable/ic_loop_disabled.xml create mode 100644 app/src/main/res/drawable/ic_loop_large.xml create mode 100644 app/src/main/res/drawable/ic_loop_one_large.xml create mode 100644 app/src/main/res/drawable/ic_shuffle_disabled.xml create mode 100644 app/src/main/res/drawable/ic_skip_next_large.xml create mode 100644 app/src/main/res/drawable/ic_skip_prev_large.xml diff --git a/app/src/main/java/org/oxycblt/auxio/music/coil/CoilUtils.kt b/app/src/main/java/org/oxycblt/auxio/music/coil/CoilUtils.kt index 644580d0f..e0a26010b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/coil/CoilUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/coil/CoilUtils.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt index e61387df2..93780d6da 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt @@ -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() - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt index abdcc37df..52c5debc2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackNotificationHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackNotificationHolder.kt index a0e5c86e2..1d1fb3c7f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackNotificationHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackNotificationHolder.kt @@ -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" } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt index ef3d3b554..22e819133 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt @@ -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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index b79acdd7d..d6c0f293e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index faf83fcea..e6d37eed4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -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) { diff --git a/app/src/main/res/drawable/ic_loop.xml b/app/src/main/res/drawable/ic_loop.xml index 52e22f421..962a21eed 100644 --- a/app/src/main/res/drawable/ic_loop.xml +++ b/app/src/main/res/drawable/ic_loop.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/drawable/ic_loop_disabled.xml b/app/src/main/res/drawable/ic_loop_disabled.xml new file mode 100644 index 000000000..fcf057385 --- /dev/null +++ b/app/src/main/res/drawable/ic_loop_disabled.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_loop_large.xml b/app/src/main/res/drawable/ic_loop_large.xml new file mode 100644 index 000000000..52e22f421 --- /dev/null +++ b/app/src/main/res/drawable/ic_loop_large.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_loop_one.xml b/app/src/main/res/drawable/ic_loop_one.xml index d9f548be3..89b912ce9 100644 --- a/app/src/main/res/drawable/ic_loop_one.xml +++ b/app/src/main/res/drawable/ic_loop_one.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/drawable/ic_loop_one_large.xml b/app/src/main/res/drawable/ic_loop_one_large.xml new file mode 100644 index 000000000..d9f548be3 --- /dev/null +++ b/app/src/main/res/drawable/ic_loop_one_large.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml index 60ef4d1d3..be458bd95 100644 --- a/app/src/main/res/drawable/ic_pause.xml +++ b/app/src/main/res/drawable/ic_pause.xml @@ -1,11 +1,11 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml index 3dfcbc471..c2f1de008 100644 --- a/app/src/main/res/drawable/ic_play.xml +++ b/app/src/main/res/drawable/ic_play.xml @@ -1,11 +1,11 @@ + 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" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shuffle_disabled.xml b/app/src/main/res/drawable/ic_shuffle_disabled.xml new file mode 100644 index 000000000..00f5aee33 --- /dev/null +++ b/app/src/main/res/drawable/ic_shuffle_disabled.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml index 6826f7388..cb217d9cb 100644 --- a/app/src/main/res/drawable/ic_skip_next.xml +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -1,9 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_skip_prev.xml b/app/src/main/res/drawable/ic_skip_prev.xml index 2704abb08..38f80a46b 100644 --- a/app/src/main/res/drawable/ic_skip_prev.xml +++ b/app/src/main/res/drawable/ic_skip_prev.xml @@ -1,9 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playback.xml b/app/src/main/res/layout/fragment_playback.xml index 7c98de237..1a394b065 100644 --- a/app/src/main/res/layout/fragment_playback.xml +++ b/app/src/main/res/layout/fragment_playback.xml @@ -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"