mirror of
https://github.com/libre-tube/LibreTube.git
synced 2024-12-13 22:00:30 +05:30
feat: playing queue support for downloaded videos
This commit is contained in:
parent
db8ec51b12
commit
9030a6e871
@ -3,6 +3,7 @@ package com.github.libretube.db.obj
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.libretube.api.obj.StreamItem
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.nio.file.Path
|
||||
|
||||
@ -17,4 +18,14 @@ data class Download(
|
||||
val duration: Long? = null,
|
||||
val uploadDate: LocalDate? = 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,
|
||||
)
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@ -23,24 +22,15 @@ 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.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.PlayerEvent
|
||||
import com.github.libretube.extensions.serializableExtra
|
||||
import com.github.libretube.extensions.toAndroidUri
|
||||
import com.github.libretube.extensions.updateParameters
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
import com.github.libretube.util.NowPlayingNotification
|
||||
import com.github.libretube.util.PauseableTimer
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.io.path.exists
|
||||
|
||||
@UnstableApi
|
||||
abstract class AbstractPlayerService : LifecycleService() {
|
||||
@ -138,6 +128,8 @@ abstract class AbstractPlayerService : LifecycleService() {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
PlayingQueue.resetToDefaults()
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (intent != null) {
|
||||
createPlayerAndNotification()
|
||||
|
@ -8,11 +8,13 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.github.libretube.constants.IntentData
|
||||
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.extensions.toAndroidUri
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -23,39 +25,28 @@ import kotlin.io.path.exists
|
||||
*/
|
||||
@UnstableApi
|
||||
class OfflinePlayerService : AbstractPlayerService() {
|
||||
private var downloadsWithItems: List<DownloadWithItems> = emptyList()
|
||||
|
||||
override suspend fun onServiceCreated(intent: Intent) {
|
||||
downloadsWithItems = withContext(Dispatchers.IO) {
|
||||
DatabaseHolder.Database.downloadDao().getAll()
|
||||
}
|
||||
if (downloadsWithItems.isEmpty()) {
|
||||
onDestroy()
|
||||
return
|
||||
videoId = intent.getStringExtra(IntentData.videoId) ?: return
|
||||
|
||||
PlayingQueue.clear()
|
||||
|
||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
||||
streamItem.url?.toID()?.let { playNextVideo(it) }
|
||||
}
|
||||
|
||||
val videoId = intent.getStringExtra(IntentData.videoId)
|
||||
|
||||
val downloadToPlay = if (videoId == null) {
|
||||
downloadsWithItems = downloadsWithItems.shuffled()
|
||||
downloadsWithItems.first()
|
||||
} else {
|
||||
downloadsWithItems.first { it.download.videoId == videoId }
|
||||
}
|
||||
|
||||
this@OfflinePlayerService.videoId = downloadToPlay.download.videoId
|
||||
fillQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to start an audio player with the given download items
|
||||
*/
|
||||
override suspend fun startPlaybackAndUpdateNotification() {
|
||||
val downloadWithItems = downloadsWithItems.firstOrNull { it.download.videoId == videoId }
|
||||
if (downloadWithItems == null) {
|
||||
stopSelf()
|
||||
return
|
||||
val downloadWithItems = withContext(Dispatchers.IO) {
|
||||
Database.downloadDao().findById(videoId)
|
||||
}
|
||||
|
||||
PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem())
|
||||
|
||||
val notificationData = PlayerNotificationData(
|
||||
title = downloadWithItems.download.title,
|
||||
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? {
|
||||
super.onBind(intent)
|
||||
return null
|
||||
@ -103,15 +112,8 @@ class OfflinePlayerService : AbstractPlayerService() {
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
// automatically go to the next video/audio when the current one ended
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
val currentIndex = downloadsWithItems.indexOfFirst { it.download.videoId == videoId }
|
||||
downloadsWithItems.getOrNull(currentIndex + 1)?.let {
|
||||
this@OfflinePlayerService.videoId = it.download.videoId
|
||||
|
||||
lifecycleScope.launch {
|
||||
startPlaybackAndUpdateNotification()
|
||||
}
|
||||
}
|
||||
if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) {
|
||||
playNextVideo(PlayingQueue.getNext() ?: return)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,13 @@
|
||||
package com.github.libretube.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Binder
|
||||
import android.os.Handler
|
||||
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.lifecycle.LifecycleService
|
||||
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.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
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.RetrofitInstance
|
||||
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.constants.IntentData
|
||||
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.serializableExtra
|
||||
import com.github.libretube.extensions.setMetadata
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
||||
import com.github.libretube.extensions.updateParameters
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.helpers.PlayerHelper.checkForSegments
|
||||
import com.github.libretube.helpers.ProxyHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -90,9 +66,6 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null
|
||||
|
||||
override suspend fun onServiceCreated(intent: Intent) {
|
||||
// reset the playing queue listeners
|
||||
PlayingQueue.resetToDefaults()
|
||||
|
||||
val playerData = intent.parcelableExtra<PlayerData>(IntentData.playerData)
|
||||
if (playerData == null) {
|
||||
stopSelf()
|
||||
|
@ -49,6 +49,7 @@ import com.github.libretube.ui.models.OfflinePlayerViewModel
|
||||
import com.github.libretube.util.NowPlayingNotification
|
||||
import com.github.libretube.util.OfflineTimeFrameReceiver
|
||||
import com.github.libretube.util.PauseableTimer
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -85,7 +86,10 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
|
||||
if (PlayerHelper.pipEnabled) {
|
||||
PictureInPictureCompat.setPictureInPictureParams(this@OfflinePlayerActivity, pipParams)
|
||||
PictureInPictureCompat.setPictureInPictureParams(
|
||||
this@OfflinePlayerActivity,
|
||||
pipParams
|
||||
)
|
||||
}
|
||||
|
||||
// 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() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
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))
|
||||
.setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying)
|
||||
.setAspectRatio(viewModel.player.videoSize)
|
||||
@ -136,6 +151,13 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
PlayingQueue.resetToDefaults()
|
||||
PlayingQueue.clear()
|
||||
|
||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
||||
playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
|
||||
}
|
||||
|
||||
initializePlayer()
|
||||
playVideo()
|
||||
|
||||
@ -154,6 +176,14 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
if (PlayerHelper.pipEnabled) {
|
||||
PictureInPictureCompat.setPictureInPictureParams(this, pipParams)
|
||||
}
|
||||
|
||||
lifecycleScope.launch { fillQueue() }
|
||||
}
|
||||
|
||||
private fun playNextVideo(videoId: String) {
|
||||
saveWatchPosition()
|
||||
this.videoId = videoId
|
||||
playVideo()
|
||||
}
|
||||
|
||||
private fun initializePlayer() {
|
||||
@ -171,13 +201,25 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
playerBinding.skipPrev.setOnClickListener {
|
||||
playNextVideo(PlayingQueue.getPrev() ?: return@setOnClickListener)
|
||||
}
|
||||
|
||||
playerBinding.skipNext.setOnClickListener {
|
||||
playNextVideo(PlayingQueue.getNext() ?: return@setOnClickListener)
|
||||
}
|
||||
|
||||
binding.player.initialize(
|
||||
binding.doubleTapOverlay.binding,
|
||||
binding.playerGestureControlsView.binding,
|
||||
chaptersViewModel
|
||||
)
|
||||
|
||||
nowPlayingNotification = NowPlayingNotification(this, viewModel.player, NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE)
|
||||
nowPlayingNotification = NowPlayingNotification(
|
||||
this,
|
||||
viewModel.player,
|
||||
NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE
|
||||
)
|
||||
}
|
||||
|
||||
private fun playVideo() {
|
||||
@ -185,6 +227,8 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) {
|
||||
Database.downloadDao().findById(videoId)
|
||||
}
|
||||
PlayingQueue.updateCurrent(downloadInfo.toStreamItem())
|
||||
|
||||
val chapters = downloadChapters.map(DownloadChapter::toChapterSegment)
|
||||
chaptersViewModel.chaptersLiveData.value = 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)
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
if (!PlayerHelper.watchPositionsVideo) return
|
||||
|
||||
@ -320,7 +376,10 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
super.onUserLeaveHint()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, configuration: Configuration) {
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
|
||||
if (isInPictureInPictureMode) {
|
||||
|
@ -171,10 +171,6 @@ class DownloadsFragment : DynamicLayoutManagerFragment() {
|
||||
|
||||
toggleButtonsVisibility()
|
||||
|
||||
binding.shuffleBackground.setOnClickListener {
|
||||
BackgroundHelper.playOnBackgroundOffline(requireContext(), null)
|
||||
}
|
||||
|
||||
binding.deleteAll.setOnClickListener {
|
||||
showDeleteAllDialog(binding.root.context, adapter)
|
||||
}
|
||||
@ -188,7 +184,6 @@ class DownloadsFragment : DynamicLayoutManagerFragment() {
|
||||
binding.downloads.isGone = isEmpty
|
||||
binding.sortType.isGone = isEmpty
|
||||
binding.deleteAll.isGone = isEmpty
|
||||
binding.shuffleBackground.isGone = isEmpty
|
||||
}
|
||||
|
||||
private fun sortDownloadList(sortType: Int, previousSortType: Int? = null) {
|
||||
|
@ -66,6 +66,7 @@ import com.github.libretube.ui.models.ChaptersViewModel
|
||||
import com.github.libretube.ui.sheets.BaseBottomSheet
|
||||
import com.github.libretube.ui.sheets.ChaptersBottomSheet
|
||||
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.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
|
||||
updateDisplayedDurationType()
|
||||
|
||||
@ -248,6 +255,10 @@ abstract class CustomExoPlayerView(
|
||||
sheet.show(activity.supportFragmentManager)
|
||||
}
|
||||
}
|
||||
|
||||
binding.queueToggle.setOnClickListener {
|
||||
PlayingQueueSheet().show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -173,18 +173,6 @@ class OnlinePlayerView(
|
||||
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 = {
|
||||
binding.sbToggle.setImageResource(
|
||||
if (playerViewModel.sponsorBlockEnabled) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled
|
||||
|
@ -76,7 +76,6 @@
|
||||
android:scaleY="0.8"
|
||||
android:thumb="@drawable/player_switch_thumb"
|
||||
android:tooltipText="@string/player_autoplay"
|
||||
android:visibility="gone"
|
||||
app:thumbTint="@android:color/white"
|
||||
app:track="@drawable/player_switch_track"
|
||||
app:trackTint="#88ffffff" />
|
||||
@ -114,7 +113,6 @@
|
||||
android:layout_marginEnd="2dp"
|
||||
android:src="@drawable/ic_queue"
|
||||
android:tooltipText="@string/queue"
|
||||
android:visibility="gone"
|
||||
app:tint="@android:color/white" />
|
||||
|
||||
<ImageButton
|
||||
|
@ -66,7 +66,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="18dp"
|
||||
android:layout_marginBottom="80dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:contentDescription="@string/shuffle"
|
||||
android:src="@drawable/ic_delete"
|
||||
android:tooltipText="@string/delete"
|
||||
@ -76,19 +76,4 @@
|
||||
tools:targetApi="o"
|
||||
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>
|
Loading…
Reference in New Issue
Block a user