feat: playing queue support for downloaded videos

This commit is contained in:
Bnyro 2024-10-06 13:43:28 +02:00
parent db8ec51b12
commit 9030a6e871
10 changed files with 129 additions and 115 deletions

View File

@ -3,6 +3,7 @@ package com.github.libretube.db.obj
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.libretube.api.obj.StreamItem
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.nio.file.Path import java.nio.file.Path
@ -17,4 +18,14 @@ data class Download(
val duration: Long? = null, val duration: Long? = null,
val uploadDate: LocalDate? = null, val uploadDate: LocalDate? = null,
val thumbnailPath: Path? = null val thumbnailPath: Path? = null
) ) {
fun toStreamItem() = StreamItem(
url = videoId,
title = title,
shortDescription = description,
thumbnail = thumbnailPath?.toUri()?.toString(),
duration = duration,
uploadedDate = uploadDate?.toString(),
uploaderName = uploader,
)
}

View File

@ -15,7 +15,6 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@ -23,24 +22,15 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.DownloadWithItems
import com.github.libretube.enums.FileType
import com.github.libretube.enums.NotificationId import com.github.libretube.enums.NotificationId
import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.serializableExtra import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.extensions.updateParameters import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.io.path.exists
@UnstableApi @UnstableApi
abstract class AbstractPlayerService : LifecycleService() { abstract class AbstractPlayerService : LifecycleService() {
@ -138,6 +128,8 @@ abstract class AbstractPlayerService : LifecycleService() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
PlayingQueue.resetToDefaults()
lifecycleScope.launch { lifecycleScope.launch {
if (intent != null) { if (intent != null) {
createPlayerAndNotification() createPlayerAndNotification()

View File

@ -8,11 +8,13 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.DownloadWithItems import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.enums.FileType import com.github.libretube.enums.FileType
import com.github.libretube.extensions.toAndroidUri import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -23,39 +25,28 @@ import kotlin.io.path.exists
*/ */
@UnstableApi @UnstableApi
class OfflinePlayerService : AbstractPlayerService() { class OfflinePlayerService : AbstractPlayerService() {
private var downloadsWithItems: List<DownloadWithItems> = emptyList()
override suspend fun onServiceCreated(intent: Intent) { override suspend fun onServiceCreated(intent: Intent) {
downloadsWithItems = withContext(Dispatchers.IO) { videoId = intent.getStringExtra(IntentData.videoId) ?: return
DatabaseHolder.Database.downloadDao().getAll()
} PlayingQueue.clear()
if (downloadsWithItems.isEmpty()) {
onDestroy() PlayingQueue.setOnQueueTapListener { streamItem ->
return streamItem.url?.toID()?.let { playNextVideo(it) }
} }
val videoId = intent.getStringExtra(IntentData.videoId) fillQueue()
val downloadToPlay = if (videoId == null) {
downloadsWithItems = downloadsWithItems.shuffled()
downloadsWithItems.first()
} else {
downloadsWithItems.first { it.download.videoId == videoId }
}
this@OfflinePlayerService.videoId = downloadToPlay.download.videoId
} }
/** /**
* Attempt to start an audio player with the given download items * Attempt to start an audio player with the given download items
*/ */
override suspend fun startPlaybackAndUpdateNotification() { override suspend fun startPlaybackAndUpdateNotification() {
val downloadWithItems = downloadsWithItems.firstOrNull { it.download.videoId == videoId } val downloadWithItems = withContext(Dispatchers.IO) {
if (downloadWithItems == null) { Database.downloadDao().findById(videoId)
stopSelf()
return
} }
PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem())
val notificationData = PlayerNotificationData( val notificationData = PlayerNotificationData(
title = downloadWithItems.download.title, title = downloadWithItems.download.title,
uploaderName = downloadWithItems.download.uploader, uploaderName = downloadWithItems.download.uploader,
@ -88,6 +79,24 @@ class OfflinePlayerService : AbstractPlayerService() {
} }
} }
private suspend fun fillQueue() {
val downloads = withContext(Dispatchers.IO) {
Database.downloadDao().getAll()
}
PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() })
}
private fun playNextVideo(videoId: String) {
saveWatchPosition()
this.videoId = videoId
lifecycleScope.launch {
startPlaybackAndUpdateNotification()
}
}
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
super.onBind(intent) super.onBind(intent)
return null return null
@ -103,15 +112,8 @@ class OfflinePlayerService : AbstractPlayerService() {
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
// automatically go to the next video/audio when the current one ended // automatically go to the next video/audio when the current one ended
if (playbackState == Player.STATE_ENDED) { if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) {
val currentIndex = downloadsWithItems.indexOfFirst { it.download.videoId == videoId } playNextVideo(PlayingQueue.getNext() ?: return)
downloadsWithItems.getOrNull(currentIndex + 1)?.let {
this@OfflinePlayerService.videoId = it.download.videoId
lifecycleScope.launch {
startPlaybackAndUpdateNotification()
}
}
} }
} }
} }

View File

@ -1,31 +1,13 @@
package com.github.libretube.services package com.github.libretube.services
import android.app.Notification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Binder import android.os.Binder
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.C.WAKE_MODE_NETWORK
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
import com.github.libretube.R
import com.github.libretube.api.JsonHelper import com.github.libretube.api.JsonHelper
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.StreamsExtractor import com.github.libretube.api.StreamsExtractor
@ -33,21 +15,15 @@ import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHelper
import com.github.libretube.enums.NotificationId
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.parcelableExtra import com.github.libretube.extensions.parcelableExtra
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.checkForSegments import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.parcelable.PlayerData import com.github.libretube.parcelable.PlayerData
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -90,9 +66,6 @@ class OnlinePlayerService : AbstractPlayerService() {
var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null
override suspend fun onServiceCreated(intent: Intent) { override suspend fun onServiceCreated(intent: Intent) {
// reset the playing queue listeners
PlayingQueue.resetToDefaults()
val playerData = intent.parcelableExtra<PlayerData>(IntentData.playerData) val playerData = intent.parcelableExtra<PlayerData>(IntentData.playerData)
if (playerData == null) { if (playerData == null) {
stopSelf() stopSelf()

View File

@ -49,6 +49,7 @@ import com.github.libretube.ui.models.OfflinePlayerViewModel
import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.OfflineTimeFrameReceiver import com.github.libretube.util.OfflineTimeFrameReceiver
import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -85,7 +86,10 @@ class OfflinePlayerActivity : BaseActivity() {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
if (PlayerHelper.pipEnabled) { if (PlayerHelper.pipEnabled) {
PictureInPictureCompat.setPictureInPictureParams(this@OfflinePlayerActivity, pipParams) PictureInPictureCompat.setPictureInPictureParams(
this@OfflinePlayerActivity,
pipParams
)
} }
// Start or pause watch position timer // Start or pause watch position timer
@ -108,17 +112,28 @@ class OfflinePlayerActivity : BaseActivity() {
) )
) )
} }
if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) {
playNextVideo(PlayingQueue.getNext() ?: return)
}
} }
} }
private val playerActionReceiver = object : BroadcastReceiver() { private val playerActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
PlayerHelper.handlePlayerAction(viewModel.player, event) if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return
when (event) {
PlayerEvent.Prev -> playNextVideo(PlayingQueue.getPrev() ?: return)
PlayerEvent.Next -> playNextVideo(PlayingQueue.getNext() ?: return)
else -> Unit
}
} }
} }
private val pipParams get() = PictureInPictureParamsCompat.Builder() private val pipParams
get() = PictureInPictureParamsCompat.Builder()
.setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying)) .setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying))
.setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying) .setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying)
.setAspectRatio(viewModel.player.videoSize) .setAspectRatio(viewModel.player.videoSize)
@ -136,6 +151,13 @@ class OfflinePlayerActivity : BaseActivity() {
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater) binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
PlayingQueue.resetToDefaults()
PlayingQueue.clear()
PlayingQueue.setOnQueueTapListener { streamItem ->
playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
}
initializePlayer() initializePlayer()
playVideo() playVideo()
@ -154,6 +176,14 @@ class OfflinePlayerActivity : BaseActivity() {
if (PlayerHelper.pipEnabled) { if (PlayerHelper.pipEnabled) {
PictureInPictureCompat.setPictureInPictureParams(this, pipParams) PictureInPictureCompat.setPictureInPictureParams(this, pipParams)
} }
lifecycleScope.launch { fillQueue() }
}
private fun playNextVideo(videoId: String) {
saveWatchPosition()
this.videoId = videoId
playVideo()
} }
private fun initializePlayer() { private fun initializePlayer() {
@ -171,13 +201,25 @@ class OfflinePlayerActivity : BaseActivity() {
finish() finish()
} }
playerBinding.skipPrev.setOnClickListener {
playNextVideo(PlayingQueue.getPrev() ?: return@setOnClickListener)
}
playerBinding.skipNext.setOnClickListener {
playNextVideo(PlayingQueue.getNext() ?: return@setOnClickListener)
}
binding.player.initialize( binding.player.initialize(
binding.doubleTapOverlay.binding, binding.doubleTapOverlay.binding,
binding.playerGestureControlsView.binding, binding.playerGestureControlsView.binding,
chaptersViewModel chaptersViewModel
) )
nowPlayingNotification = NowPlayingNotification(this, viewModel.player, NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE) nowPlayingNotification = NowPlayingNotification(
this,
viewModel.player,
NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE
)
} }
private fun playVideo() { private fun playVideo() {
@ -185,6 +227,8 @@ class OfflinePlayerActivity : BaseActivity() {
val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) { val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) {
Database.downloadDao().findById(videoId) Database.downloadDao().findById(videoId)
} }
PlayingQueue.updateCurrent(downloadInfo.toStreamItem())
val chapters = downloadChapters.map(DownloadChapter::toChapterSegment) val chapters = downloadChapters.map(DownloadChapter::toChapterSegment)
chaptersViewModel.chaptersLiveData.value = chapters chaptersViewModel.chaptersLiveData.value = chapters
binding.player.setChapters(chapters) binding.player.setChapters(chapters)
@ -221,7 +265,11 @@ class OfflinePlayerActivity : BaseActivity() {
} }
} }
val data = PlayerNotificationData(downloadInfo.title, downloadInfo.uploader, downloadInfo.thumbnailPath.toString()) val data = PlayerNotificationData(
downloadInfo.title,
downloadInfo.uploader,
downloadInfo.thumbnailPath.toString()
)
nowPlayingNotification?.updatePlayerNotification(videoId, data) nowPlayingNotification?.updatePlayerNotification(videoId, data)
} }
} }
@ -274,6 +322,14 @@ class OfflinePlayerActivity : BaseActivity() {
} }
} }
private suspend fun fillQueue() {
val downloads = withContext(Dispatchers.IO) {
Database.downloadDao().getAll()
}
PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() })
}
private fun saveWatchPosition() { private fun saveWatchPosition() {
if (!PlayerHelper.watchPositionsVideo) return if (!PlayerHelper.watchPositionsVideo) return
@ -320,7 +376,10 @@ class OfflinePlayerActivity : BaseActivity() {
super.onUserLeaveHint() super.onUserLeaveHint()
} }
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, configuration: Configuration) { override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode) super.onPictureInPictureModeChanged(isInPictureInPictureMode)
if (isInPictureInPictureMode) { if (isInPictureInPictureMode) {

View File

@ -171,10 +171,6 @@ class DownloadsFragment : DynamicLayoutManagerFragment() {
toggleButtonsVisibility() toggleButtonsVisibility()
binding.shuffleBackground.setOnClickListener {
BackgroundHelper.playOnBackgroundOffline(requireContext(), null)
}
binding.deleteAll.setOnClickListener { binding.deleteAll.setOnClickListener {
showDeleteAllDialog(binding.root.context, adapter) showDeleteAllDialog(binding.root.context, adapter)
} }
@ -188,7 +184,6 @@ class DownloadsFragment : DynamicLayoutManagerFragment() {
binding.downloads.isGone = isEmpty binding.downloads.isGone = isEmpty
binding.sortType.isGone = isEmpty binding.sortType.isGone = isEmpty
binding.deleteAll.isGone = isEmpty binding.deleteAll.isGone = isEmpty
binding.shuffleBackground.isGone = isEmpty
} }
private fun sortDownloadList(sortType: Int, previousSortType: Int? = null) { private fun sortDownloadList(sortType: Int, previousSortType: Int? = null) {

View File

@ -66,6 +66,7 @@ import com.github.libretube.ui.models.ChaptersViewModel
import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.sheets.BaseBottomSheet
import com.github.libretube.ui.sheets.ChaptersBottomSheet import com.github.libretube.ui.sheets.ChaptersBottomSheet
import com.github.libretube.ui.sheets.PlaybackOptionsSheet import com.github.libretube.ui.sheets.PlaybackOptionsSheet
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.ui.sheets.SleepTimerSheet import com.github.libretube.ui.sheets.SleepTimerSheet
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
@ -203,6 +204,12 @@ abstract class CustomExoPlayerView(
} }
}) })
binding.autoPlay.isChecked = PlayerHelper.autoPlayEnabled
binding.autoPlay.setOnCheckedChangeListener { _, isChecked ->
PlayerHelper.autoPlayEnabled = isChecked
}
// restore the duration type from the previous session // restore the duration type from the previous session
updateDisplayedDurationType() updateDisplayedDurationType()
@ -248,6 +255,10 @@ abstract class CustomExoPlayerView(
sheet.show(activity.supportFragmentManager) sheet.show(activity.supportFragmentManager)
} }
} }
binding.queueToggle.setOnClickListener {
PlayingQueueSheet().show(supportFragmentManager, null)
}
} }
/** /**

View File

@ -173,18 +173,6 @@ class OnlinePlayerView(
binding.exoTitle.isInvisible = !isFullscreen binding.exoTitle.isInvisible = !isFullscreen
} }
binding.autoPlay.isVisible = true
binding.autoPlay.isChecked = PlayerHelper.autoPlayEnabled
binding.autoPlay.setOnCheckedChangeListener { _, isChecked ->
PlayerHelper.autoPlayEnabled = isChecked
}
binding.queueToggle.isVisible = true
binding.queueToggle.setOnClickListener {
PlayingQueueSheet().show(activity.supportFragmentManager, null)
}
val updateSbImageResource = { val updateSbImageResource = {
binding.sbToggle.setImageResource( binding.sbToggle.setImageResource(
if (playerViewModel.sponsorBlockEnabled) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled if (playerViewModel.sponsorBlockEnabled) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled

View File

@ -76,7 +76,6 @@
android:scaleY="0.8" android:scaleY="0.8"
android:thumb="@drawable/player_switch_thumb" android:thumb="@drawable/player_switch_thumb"
android:tooltipText="@string/player_autoplay" android:tooltipText="@string/player_autoplay"
android:visibility="gone"
app:thumbTint="@android:color/white" app:thumbTint="@android:color/white"
app:track="@drawable/player_switch_track" app:track="@drawable/player_switch_track"
app:trackTint="#88ffffff" /> app:trackTint="#88ffffff" />
@ -114,7 +113,6 @@
android:layout_marginEnd="2dp" android:layout_marginEnd="2dp"
android:src="@drawable/ic_queue" android:src="@drawable/ic_queue"
android:tooltipText="@string/queue" android:tooltipText="@string/queue"
android:visibility="gone"
app:tint="@android:color/white" /> app:tint="@android:color/white" />
<ImageButton <ImageButton

View File

@ -66,7 +66,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:layout_marginEnd="18dp" android:layout_marginEnd="18dp"
android:layout_marginBottom="80dp" android:layout_marginBottom="18dp"
android:contentDescription="@string/shuffle" android:contentDescription="@string/shuffle"
android:src="@drawable/ic_delete" android:src="@drawable/ic_delete"
android:tooltipText="@string/delete" android:tooltipText="@string/delete"
@ -76,19 +76,4 @@
tools:targetApi="o" tools:targetApi="o"
tools:visibility="visible" /> tools:visibility="visible" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/shuffle_background"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="18dp"
android:contentDescription="@string/shuffle"
android:src="@drawable/ic_shuffle"
android:tooltipText="@string/shuffle"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:targetApi="o"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>