Merge pull request #1647 from Bnyro/queue

New playing queue
This commit is contained in:
Bnyro 2022-10-23 16:06:25 +02:00 committed by GitHub
commit 6ab3c96f87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 437 additions and 232 deletions

View File

@ -0,0 +1,7 @@
package com.github.libretube.extensions
fun <T> MutableList<T>.move(oldPosition: Int, newPosition: Int) {
val item = this.get(oldPosition)
this.removeAt(oldPosition)
this.add(newPosition, item)
}

View File

@ -0,0 +1,21 @@
package com.github.libretube.extensions
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams
fun Streams.toStreamItem(videoId: String): StreamItem {
return StreamItem(
url = videoId,
title = title,
thumbnail = thumbnailUrl,
uploaderName = uploader,
uploaderUrl = uploaderUrl,
uploaderAvatar = uploaderAvatar,
uploadedDate = uploadDate,
uploaded = null,
duration = duration,
views = views,
uploaderVerified = uploaderVerified,
shortDescription = description
)
}

View File

@ -24,8 +24,7 @@ import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.extensions.awaitQuery
import com.github.libretube.extensions.toID
import com.github.libretube.util.AutoPlayHelper
import com.github.libretube.extensions.toStreamItem
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PlayerHelper
import com.github.libretube.util.PlayingQueue
@ -80,16 +79,6 @@ class BackgroundMode : Service() {
*/
private lateinit var nowPlayingNotification: NowPlayingNotification
/**
* The [videoId] of the next stream for autoplay
*/
private var nextStreamId: String? = null
/**
* Helper for finding the next video in the playlist
*/
private lateinit var autoPlayHelper: AutoPlayHelper
/**
* Autoplay Preference
*/
@ -132,9 +121,6 @@ class BackgroundMode : Service() {
playlistId = intent.getStringExtra(IntentData.playlistId)
val position = intent.getLongExtra(IntentData.position, 0L)
// initialize the playlist autoPlay Helper
autoPlayHelper = AutoPlayHelper(playlistId)
// play the audio in the background
loadAudio(videoId, position)
@ -146,7 +132,9 @@ class BackgroundMode : Service() {
}
private fun updateWatchPosition() {
player?.currentPosition?.let { DatabaseHelper.saveWatchPosition(videoId, it) }
player?.currentPosition?.let {
DatabaseHelper.saveWatchPosition(videoId, it)
}
handler.postDelayed(this::updateWatchPosition, 500)
}
@ -157,8 +145,6 @@ class BackgroundMode : Service() {
videoId: String,
seekToPosition: Long = 0
) {
// append the video to the playing queue
PlayingQueue.add(videoId)
CoroutineScope(Dispatchers.IO).launch {
try {
streams = RetrofitInstance.api.getStreams(videoId)
@ -166,6 +152,17 @@ class BackgroundMode : Service() {
return@launch
}
// add the playlist video to the queue
if (playlistId != null && PlayingQueue.isEmpty()) {
streams?.toStreamItem(videoId)
?.let { PlayingQueue.insertPlaylist(playlistId!!, it) }
} else {
streams?.toStreamItem(videoId)?.let { PlayingQueue.updateCurrent(it) }
streams?.relatedStreams?.toTypedArray()?.let {
PlayingQueue.add(*it)
}
}
handler.post {
playAudio(seekToPosition)
}
@ -175,8 +172,6 @@ class BackgroundMode : Service() {
private fun playAudio(
seekToPosition: Long
) {
PlayingQueue.updateCurrent(videoId)
initializePlayer()
setMediaItem()
@ -218,8 +213,6 @@ class BackgroundMode : Service() {
player?.setPlaybackSpeed(playbackSpeed)
fetchSponsorBlockSegments()
if (PlayerHelper.autoPlayEnabled) setNextStream()
}
/**
@ -268,32 +261,16 @@ class BackgroundMode : Service() {
})
}
/**
* set the videoId of the next stream for autoplay
*/
private fun setNextStream() {
if (streams!!.relatedStreams!!.isNotEmpty()) {
nextStreamId = streams?.relatedStreams!![0].url!!.toID()
}
if (playlistId == null) return
if (!this::autoPlayHelper.isInitialized) autoPlayHelper = AutoPlayHelper(playlistId!!)
// search for the next videoId in the playlist
CoroutineScope(Dispatchers.IO).launch {
nextStreamId = autoPlayHelper.getNextVideoId(videoId, streams!!.relatedStreams!!)
}
}
/**
* Plays the first related video to the current (used when the playback of the current video ended)
*/
private fun playNextVideo() {
if (nextStreamId == null || nextStreamId == videoId) return
val nextQueueVideo = PlayingQueue.getNext()
if (nextQueueVideo != null) nextStreamId = nextQueueVideo
val nextVideo = PlayingQueue.getNext()
// play new video on background
this.videoId = nextStreamId!!
if (nextVideo != null) {
this.videoId = nextVideo
}
this.segmentData = null
loadAudio(videoId)
}

View File

@ -34,8 +34,10 @@ import com.github.libretube.services.ClosingService
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.ErrorDialog
import com.github.libretube.ui.fragments.PlayerFragment
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.util.NavBarHelper
import com.github.libretube.util.NetworkHelper
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.PreferenceHelper
import com.github.libretube.util.ThemeHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -195,6 +197,11 @@ class MainActivity : BaseActivity() {
)
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
menu?.findItem(R.id.action_queue)?.isVisible = PlayingQueue.isNotEmpty()
return super.onPrepareOptionsMenu(menu)
}
/**
* Initialize the notification badge showing the amount of new videos
*/
@ -302,6 +309,10 @@ class MainActivity : BaseActivity() {
startActivity(communityIntent)
true
}
R.id.action_queue -> {
PlayingQueueSheet().show(supportFragmentManager, null)
true
}
else -> super.onOptionsItemSelected(item)
}
}

View File

@ -0,0 +1,44 @@
package com.github.libretube.ui.adapters
import android.annotation.SuppressLint
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.QueueRowBinding
import com.github.libretube.ui.viewholders.PlayingQueueViewHolder
import com.github.libretube.util.ImageHelper
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.ThemeHelper
class PlayingQueueAdapter : RecyclerView.Adapter<PlayingQueueViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlayingQueueViewHolder {
val binding = QueueRowBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return PlayingQueueViewHolder(binding)
}
override fun getItemCount(): Int {
return PlayingQueue.size()
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: PlayingQueueViewHolder, position: Int) {
val streamItem = PlayingQueue.getStreams()[position]
holder.binding.apply {
ImageHelper.loadImage(streamItem.thumbnail, thumbnail)
title.text = streamItem.title
videoInfo.text = streamItem.uploaderName + "" +
DateUtils.formatElapsedTime(streamItem.duration ?: 0)
if (PlayingQueue.currentIndex() == position) {
root.setBackgroundColor(
ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight)
)
}
}
}
}

View File

@ -52,6 +52,7 @@ import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.hideKeyboard
import com.github.libretube.extensions.query
import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toStreamItem
import com.github.libretube.models.PlayerViewModel
import com.github.libretube.models.interfaces.PlayerOptionsInterface
import com.github.libretube.services.BackgroundMode
@ -64,8 +65,8 @@ import com.github.libretube.ui.base.BaseFragment
import com.github.libretube.ui.dialogs.AddToPlaylistDialog
import com.github.libretube.ui.dialogs.DownloadDialog
import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.ui.views.BottomSheet
import com.github.libretube.util.AutoPlayHelper
import com.github.libretube.util.BackgroundHelper
import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NowPlayingNotification
@ -153,12 +154,6 @@ class PlayerFragment : BaseFragment() {
private var token = PreferenceHelper.getToken()
private var videoShownInExternalPlayer = false
/**
* for autoplay
*/
private var nextStreamId: String? = null
private lateinit var autoPlayHelper: AutoPlayHelper
/**
* for the player notification
*/
@ -409,6 +404,11 @@ class PlayerFragment : BaseFragment() {
toggleComments()
}
playerBinding.queueToggle.visibility = View.VISIBLE
playerBinding.queueToggle.setOnClickListener {
PlayingQueueSheet().show(childFragmentManager, null)
}
// FullScreen button trigger
// hide fullscreen button if auto rotation enabled
playerBinding.fullscreen.visibility =
@ -630,8 +630,6 @@ class PlayerFragment : BaseFragment() {
private fun playVideo() {
lifecycleScope.launchWhenCreated {
PlayingQueue.updateCurrent(videoId!!)
streams = try {
RetrofitInstance.api.getStreams(videoId!!)
} catch (e: IOException) {
@ -645,6 +643,21 @@ class PlayerFragment : BaseFragment() {
return@launchWhenCreated
}
if (PlayingQueue.isEmpty()) {
CoroutineScope(Dispatchers.IO).launch {
if (playlistId != null) {
PlayingQueue.insertPlaylist(playlistId!!, streams.toStreamItem(videoId!!))
} else {
PlayingQueue.updateCurrent(streams.toStreamItem(videoId!!))
PlayingQueue.add(
*streams.relatedStreams.orEmpty().toTypedArray()
)
}
}
} else {
PlayingQueue.updateCurrent(streams.toStreamItem(videoId!!))
}
runOnUiThread {
// hide the button to skip SponsorBlock segments manually
binding.sbSkipBtn.visibility = View.GONE
@ -668,8 +681,6 @@ class PlayerFragment : BaseFragment() {
if (PlayerHelper.sponsorBlockEnabled) fetchSponsorBlockSegments()
// show comments if related streams disabled
if (!PlayerHelper.relatedStreamsEnabled) toggleComments()
// prepare for autoplay
if (binding.player.autoplayEnabled) setNextStream()
// add the video to the watch history
if (PlayerHelper.watchHistoryEnabled) {
@ -682,17 +693,6 @@ class PlayerFragment : BaseFragment() {
}
}
/**
* set the videoId of the next stream for autoplay
*/
private fun setNextStream() {
if (!this::autoPlayHelper.isInitialized) autoPlayHelper = AutoPlayHelper(playlistId)
// search for the next videoId in the playlist
lifecycleScope.launchWhenCreated {
nextStreamId = autoPlayHelper.getNextVideoId(videoId!!, streams.relatedStreams)
}
}
/**
* fetch the segments for SponsorBlock
*/
@ -758,19 +758,20 @@ class PlayerFragment : BaseFragment() {
// used for autoplay and skipping to next video
private fun playNextVideo() {
if (nextStreamId == null) return
// check whether there is a new video in the queue
val nextQueueVideo = PlayingQueue.getNext()
if (nextQueueVideo != null) nextStreamId = nextQueueVideo
val nextVideoId = PlayingQueue.getNext()
// by making sure that the next and the current video aren't the same
saveWatchPosition()
// save the id of the next stream as videoId and load the next video
if (nextVideoId != null) {
videoId = nextVideoId
// forces the comments to reload for the new video
commentsLoaded = false
binding.commentsRecView.adapter = null
// save the id of the next stream as videoId and load the next video
videoId = nextStreamId
playVideo()
}
}
private fun prepareExoPlayerView() {
exoPlayerView.apply {
@ -866,7 +867,6 @@ class PlayerFragment : BaseFragment() {
@Suppress("DEPRECATION")
if (
playbackState == Player.STATE_ENDED &&
nextStreamId != null &&
!transitioning &&
binding.player.autoplayEnabled
) {

View File

@ -0,0 +1,66 @@
package com.github.libretube.ui.sheets
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.BottomSheetBinding
import com.github.libretube.ui.adapters.PlayingQueueAdapter
import com.github.libretube.util.PlayingQueue
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class PlayingQueueSheet : BottomSheetDialogFragment() {
private lateinit var binding: BottomSheetBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = BottomSheetBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.optionsRecycler.layoutManager = LinearLayoutManager(context)
val adapter = PlayingQueueAdapter()
binding.optionsRecycler.adapter = adapter
val callback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
ItemTouchHelper.LEFT
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.absoluteAdapterPosition
val to = target.absoluteAdapterPosition
adapter.notifyItemMoved(from, to)
PlayingQueue.move(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.absoluteAdapterPosition
if (position == PlayingQueue.currentIndex()) {
adapter.notifyItemChanged(position)
return
}
PlayingQueue.remove(position)
adapter.notifyItemRemoved(position)
adapter.notifyItemRangeChanged(position, adapter.itemCount)
}
}
val itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper.attachToRecyclerView(binding.optionsRecycler)
}
}

View File

@ -3,8 +3,10 @@ package com.github.libretube.ui.sheets
import android.os.Bundle
import android.widget.Toast
import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.ShareObjectType
import com.github.libretube.extensions.toStreamItem
import com.github.libretube.ui.dialogs.AddToPlaylistDialog
import com.github.libretube.ui.dialogs.DownloadDialog
import com.github.libretube.ui.dialogs.ShareDialog
@ -12,6 +14,9 @@ import com.github.libretube.ui.views.BottomSheet
import com.github.libretube.util.BackgroundHelper
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.PreferenceHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Dialog with different options for a selected video.
@ -79,10 +84,28 @@ class VideoOptionsBottomSheet(
shareDialog.show(parentFragmentManager, ShareDialog::class.java.name)
}
context?.getString(R.string.play_next) -> {
PlayingQueue.addAsNext(videoId)
CoroutineScope(Dispatchers.IO).launch {
try {
PlayingQueue.addAsNext(
RetrofitInstance.api.getStreams(videoId)
.toStreamItem(videoId)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
context?.getString(R.string.add_to_queue) -> {
PlayingQueue.add(videoId)
CoroutineScope(Dispatchers.IO).launch {
try {
PlayingQueue.add(
RetrofitInstance.api.getStreams(videoId)
.toStreamItem(videoId)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
}

View File

@ -0,0 +1,8 @@
package com.github.libretube.ui.viewholders
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.QueueRowBinding
class PlayingQueueViewHolder(
val binding: QueueRowBinding
) : RecyclerView.ViewHolder(binding.root)

View File

@ -1,92 +0,0 @@
package com.github.libretube.util
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.extensions.toID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AutoPlayHelper(
private val playlistId: String?
) {
private val playlistStreamIds = mutableListOf<String>()
private var playlistNextPage: String? = null
/**
* get the id of the next video to be played
*/
suspend fun getNextVideoId(
currentVideoId: String,
relatedStreams: List<com.github.libretube.api.obj.StreamItem>?
): String? {
return if (playlistId == null) {
getNextTrendingVideoId(
relatedStreams
)
} else {
getNextPlaylistVideoId(
currentVideoId
)
}
}
/**
* get the id of the next related video
*/
private fun getNextTrendingVideoId(
relatedStreams: List<com.github.libretube.api.obj.StreamItem>?
): String? {
// don't play a video if it got played before already
if (relatedStreams == null || relatedStreams.isEmpty()) return null
var index = 0
var nextStreamId: String? = null
while (nextStreamId == null || PlayingQueue.containsBeforeCurrent(nextStreamId)) {
nextStreamId = relatedStreams[index].url!!.toID()
if (index + 1 < relatedStreams.size) {
index += 1
} else {
break
}
}
return nextStreamId
}
/**
* get the videoId of the next video in a playlist
*/
private suspend fun getNextPlaylistVideoId(currentVideoId: String): String? {
// if the playlists contain the video, then save the next video as next stream
if (playlistStreamIds.contains(currentVideoId)) {
val index = playlistStreamIds.indexOf(currentVideoId)
// check whether there's a next video
return if (index + 1 < playlistStreamIds.size) {
playlistStreamIds[index + 1]
} else if (playlistNextPage == null) {
null
} else {
getNextPlaylistVideoId(currentVideoId)
}
} else if (playlistStreamIds.isEmpty() || playlistNextPage != null) {
// fetch the next page of the playlist
return withContext(Dispatchers.IO) {
// fetch the playlists or its nextPage's videos
val playlist =
if (playlistNextPage == null) {
RetrofitInstance.authApi.getPlaylist(playlistId!!)
} else {
RetrofitInstance.authApi.getPlaylistNextPage(
playlistId!!,
playlistNextPage!!
)
}
// save the playlist urls to the list
playlistStreamIds += playlist.relatedStreams!!.map { it.url!!.toID() }
// save playlistNextPage for usage if video is not contained
playlistNextPage = playlist.nextpage
return@withContext getNextPlaylistVideoId(currentVideoId)
}
}
// return null when no nextPage is found
return null
}
}

View File

@ -1,55 +1,114 @@
package com.github.libretube.util
object PlayingQueue {
private val queue = mutableListOf<String>()
private var currentVideoId: String? = null
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.extensions.move
import com.github.libretube.extensions.toID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
fun add(videoId: String) {
if (currentVideoId == videoId) return
if (queue.contains(videoId)) queue.remove(videoId)
queue.add(videoId)
object PlayingQueue {
private val queue = mutableListOf<StreamItem>()
private var currentStream: StreamItem? = null
fun add(vararg streamItem: StreamItem) {
streamItem.forEach {
if (currentStream != it) {
if (queue.contains(it)) queue.remove(it)
queue.add(it)
}
}
}
fun addAsNext(videoId: String) {
if (currentVideoId == videoId) return
if (queue.contains(videoId)) queue.remove(videoId)
fun addAsNext(streamItem: StreamItem) {
if (currentStream == streamItem) return
if (queue.contains(streamItem)) queue.remove(streamItem)
queue.add(
queue.indexOf(currentVideoId) + 1,
videoId
currentIndex() + 1,
streamItem
)
}
fun getNext(): String? {
return try {
queue[currentIndex() + 1]
queue[currentIndex() + 1].url?.toID()
} catch (e: Exception) {
null
}
}
fun getPrev(): String? {
val index = queue.indexOf(currentVideoId)
return if (index > 0) queue[index - 1] else null
val index = queue.indexOf(currentStream)
return if (index > 0) queue[index - 1].url?.toID() else null
}
fun hasPrev(): Boolean {
return queue.indexOf(currentVideoId) > 0
return queue.indexOf(currentStream) > 0
}
fun updateCurrent(videoId: String) {
currentVideoId = videoId
queue.add(videoId)
fun updateCurrent(streamItem: StreamItem) {
currentStream = streamItem
if (!contains(streamItem)) queue.add(streamItem)
}
fun isNotEmpty() = queue.isNotEmpty()
fun isEmpty() = queue.isEmpty()
fun clear() = queue.clear()
fun currentIndex() = queue.indexOf(currentVideoId)
fun size() = queue.size
fun contains(videoId: String) = queue.contains(videoId)
fun currentIndex(): Int {
return try {
queue.indexOf(
queue.first { it.url?.toID() == currentStream?.url?.toID() }
)
} catch (e: Exception) {
0
}
}
fun containsBeforeCurrent(videoId: String): Boolean {
return queue.contains(videoId) && queue.indexOf(videoId) < currentIndex()
fun contains(streamItem: StreamItem) = queue.any { it.url?.toID() == streamItem.url?.toID() }
fun getStreams() = queue
fun remove(index: Int) = queue.removeAt(index)
fun move(from: Int, to: Int) = queue.move(from, to)
private fun fetchMoreFromPlaylist(playlistId: String, nextPage: String?) {
var playlistNextPage: String? = nextPage
CoroutineScope(Dispatchers.IO).launch {
while (playlistNextPage != null) {
RetrofitInstance.authApi.getPlaylistNextPage(
playlistId,
playlistNextPage!!
).apply {
add(
*this.relatedStreams.orEmpty().toTypedArray()
)
playlistNextPage = this.nextpage
}
}
}
}
fun insertPlaylist(playlistId: String, newCurrentStream: StreamItem) {
CoroutineScope(Dispatchers.IO).launch {
try {
val response = RetrofitInstance.authApi.getPlaylist(playlistId)
add(
*response.relatedStreams
.orEmpty()
.toTypedArray()
)
updateCurrent(newCurrentStream)
fetchMoreFromPlaylist(playlistId, response.nextpage)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M32,40q-2.45,0 -4.2,-1.675t-1.75,-4.075q0,-2.4 1.675,-4.075Q29.4,28.5 31.8,28.5q0.8,0 1.575,0.15 0.775,0.15 1.525,0.5L34.9,12L44,12v3.55h-6.1L37.9,34.3q0,2.35 -1.725,4.025Q34.45,40 32,40ZM6,31.5v-3h15.3v3ZM6,23.25v-3h23.65v3ZM6,15v-3h23.65v3Z" />
</vector>

View File

@ -67,10 +67,17 @@
android:layout_gravity="center"
android:layoutDirection="ltr">
<ImageButton
android:id="@+id/queue_toggle"
style="@style/PlayerControlTop"
android:layout_marginEnd="2dp"
android:src="@drawable/ic_queue"
android:visibility="gone"
app:tint="@android:color/white" />
<ImageButton
android:id="@+id/toggle_options"
style="@style/PlayerControlTop"
android:layout_marginTop="-1dp"
android:src="@drawable/ic_arrow_down"
app:tint="@android:color/white" />

View File

@ -298,22 +298,12 @@
app:cornerRadius="11dp" />
</LinearLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:descendantFocusability="blocksDescendants">
<com.google.android.material.card.MaterialCardView
android:id="@+id/comments_toggle"
style="@style/Widget.Material3.CardView.Elevated"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:layout_margin="16dp"
app:cardCornerRadius="18dp">
<LinearLayout
@ -343,11 +333,16 @@
</com.google.android.material.card.MaterialCardView>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:descendantFocusability="blocksDescendants">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/comments_recView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/comments_toggle"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:nestedScrollingEnabled="false"
@ -357,7 +352,6 @@
android:id="@+id/related_rec_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/comments_recView"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_ripple"
android:orientation="horizontal"
android:padding="10dp">
<androidx.cardview.widget.CardView
android:layout_width="80dp"
android:layout_height="45dp"
android:layout_gravity="center"
android:layout_marginEnd="10dp"
app:cardCornerRadius="12dp">
<ImageView
android:id="@+id/thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:src="@tools:sample/backgrounds/scenic" />
</androidx.cardview.widget.CardView>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:textStyle="bold"
tools:text="I am Hardstyle - Episode 111" />
<TextView
android:id="@+id/videoInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textSize="12sp"
tools:text="Brennan Heart" />
</LinearLayout>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="10dp"
android:src="@drawable/ic_drag" />
</LinearLayout>

View File

@ -27,4 +27,10 @@
android:title="@string/about"
app:showAsAction="never" />
<item
android:id="@+id/action_queue"
android:title="@string/queue"
app:showAsAction="never"
android:visible="false" />
</menu>

View File

@ -348,6 +348,8 @@
<string name="show_more">Show more</string>
<string name="time_code">Time code</string>
<string name="added_to_playlist">Added to playlist</string>
<string name="playing_queue">Playing queue</string>
<string name="queue">Queue</string>
<!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string>