feat: Video chapters redesign

This commit is contained in:
Bnyro 2023-08-05 11:29:56 +02:00
parent 05538f9155
commit 818b9c72fe
15 changed files with 247 additions and 196 deletions

View File

@ -14,6 +14,7 @@ import android.util.Base64
import android.view.accessibility.CaptioningManager
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.PendingIntentCompat
import androidx.core.app.RemoteActionCompat
import androidx.core.content.getSystemService
@ -38,6 +39,9 @@ import com.github.libretube.db.DatabaseHolder
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.SbSkipOptions
import com.github.libretube.extensions.updateParameters
import com.github.libretube.ui.sheets.BaseBottomSheet
import com.github.libretube.ui.sheets.ChaptersBottomSheet
import com.github.libretube.ui.sheets.ExpandedBottomSheet
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.Locale
import kotlin.math.absoluteValue
@ -530,46 +534,6 @@ object PlayerHelper {
return chapters.indexOfLast { currentPosition >= it.start }.takeIf { it >= 0 }
}
/**
* Show a dialog with the chapters provided, even if the list is empty
*/
fun showChaptersDialog(context: Context, chapters: List<ChapterSegment>, player: ExoPlayer) {
val titles = chapters.map { chapter ->
"(${DateUtils.formatElapsedTime(chapter.start)}) ${chapter.title}"
}
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(R.string.chapters)
.setItems(titles.toTypedArray()) { _, index ->
val chapter = chapters.getOrNull(index) ?: return@setItems
player.seekTo(chapter.start * 1000)
}
.create()
val handler = Handler(Looper.getMainLooper())
val highlightColor =
ThemeHelper.getThemeColor(context, android.R.attr.colorControlHighlight)
val updatePosition = Runnable {
// scroll to the current playing index in the chapter
val currentPosition =
getCurrentChapterIndex(player, chapters) ?: return@Runnable
dialog.listView.smoothScrollToPosition(currentPosition)
val children = dialog.listView.children.toList()
// reset the background colors of all chapters
children.forEach { it.setBackgroundColor(Color.TRANSPARENT) }
// highlight the current chapter
children.getOrNull(currentPosition)?.setBackgroundColor(highlightColor)
}
dialog.setOnShowListener {
updatePosition.run()
// update the position after a short delay
if (dialog.isShowing) handler.postDelayed(updatePosition, 200)
}
dialog.show()
}
fun getPosition(videoId: String, duration: Long?): Long? {
if (duration == null) return null

View File

@ -7,8 +7,9 @@ import android.view.ViewGroup
import androidx.core.graphics.ColorUtils
import androidx.media3.exoplayer.ExoPlayer
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.databinding.ChapterColumnBinding
import com.github.libretube.databinding.ChaptersRowBinding
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.ThemeHelper
import com.github.libretube.ui.viewholders.ChaptersViewHolder
@ -21,7 +22,7 @@ class ChaptersAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChaptersViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = ChapterColumnBinding.inflate(layoutInflater, parent, false)
val binding = ChaptersRowBinding.inflate(layoutInflater, parent, false)
return ChaptersViewHolder(binding)
}
@ -36,6 +37,13 @@ class ChaptersAdapter(
chapterTitle.text = chapter.title
timeStamp.text = DateUtils.formatElapsedTime(chapter.start)
val chapterEnd = chapters.getOrNull(position + 1)?.start ?: (exoPlayer.duration / 1000)
val durationSpan = chapterEnd - chapter.start
duration.text = root.context.getString(
R.string.duration_span,
DateUtils.formatElapsedTime(durationSpan)
)
val color = when {
selectedPosition == position -> {
ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight)
@ -51,7 +59,7 @@ class ChaptersAdapter(
else -> Color.TRANSPARENT
}
chapterLL.setBackgroundColor(color)
root.setBackgroundColor(color)
root.setOnClickListener {
updateSelectedPosition(position)

View File

@ -7,14 +7,14 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.databinding.DialogNavbarOptionsBinding
import com.github.libretube.databinding.SimpleOptionsRecyclerBinding
import com.github.libretube.helpers.NavBarHelper
import com.github.libretube.ui.adapters.NavBarOptionsAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class NavBarOptionsDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = DialogNavbarOptionsBinding.inflate(layoutInflater)
val binding = SimpleOptionsRecyclerBinding.inflate(layoutInflater)
val options = NavBarHelper.getNavBarItems(requireContext())
val adapter = NavBarOptionsAdapter(
options.toMutableList(),

View File

@ -13,6 +13,7 @@ import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.motion.widget.TransitionAdapter
import androidx.core.view.isGone
@ -40,6 +41,7 @@ import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.interfaces.AudioPlayerOptions
import com.github.libretube.ui.listeners.AudioPlayerThumbnailListener
import com.github.libretube.ui.models.PlayerViewModel
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.VideoOptionsBottomSheet
@ -177,7 +179,8 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
val streams = playerService.streams ?: return@setOnClickListener
val player = playerService.player ?: return@setOnClickListener
PlayerHelper.showChaptersDialog(requireContext(), streams.chapters, player)
ChaptersBottomSheet(streams.chapters, player)
.show(requireActivity().supportFragmentManager)
}
binding.miniPlayerClose.setOnClickListener {

View File

@ -95,7 +95,6 @@ import com.github.libretube.parcelable.PlayerData
import com.github.libretube.services.DownloadService
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.activities.VideoTagsAdapter
import com.github.libretube.ui.adapters.ChaptersAdapter
import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.dialogs.AddToPlaylistDialog
import com.github.libretube.ui.dialogs.DownloadDialog
@ -107,6 +106,7 @@ import com.github.libretube.ui.listeners.SeekbarPreviewListener
import com.github.libretube.ui.models.CommentsViewModel
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.sheets.BaseBottomSheet
import com.github.libretube.ui.sheets.ChaptersBottomSheet
import com.github.libretube.ui.sheets.CommentsSheet
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.util.HtmlParser
@ -774,7 +774,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// first
fetchSponsorBlockSegments()
initializeChapters()
// enable the chapters dialog in the player
playerBinding.chapterLL.setOnClickListener {
ChaptersBottomSheet(chapters, exoPlayer)
.show(requireActivity().supportFragmentManager)
}
setCurrentChapterName()
}
}
}
@ -1155,34 +1161,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
}
private fun initializeChapters() {
if (chapters.isEmpty()) {
binding.chaptersRecView.isGone = true
playerBinding.chapterLL.isInvisible = true
return
}
// show the chapter layouts
binding.chaptersRecView.isVisible = true
playerBinding.chapterLL.isVisible = true
// enable chapters in the video description
binding.chaptersRecView.layoutManager =
LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.chaptersRecView.adapter = ChaptersAdapter(chapters, exoPlayer)
// enable the chapters dialog in the player
playerBinding.chapterLL.setOnClickListener {
PlayerHelper.showChaptersDialog(requireContext(), chapters, exoPlayer)
}
setCurrentChapterName()
}
private suspend fun initializeHighlight(highlight: Segment) {
val frameReceiver = OnlineTimeFrameReceiver(requireContext(), streams.previewFrames)
val frame = withContext(Dispatchers.IO) {
@ -1197,12 +1175,14 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
chapters.sortBy { it.start }
withContext(Dispatchers.Main) {
initializeChapters()
setCurrentChapterName()
}
}
// set the name of the video chapter in the exoPlayerView
private fun setCurrentChapterName(forceUpdate: Boolean = false, enqueueNew: Boolean = true) {
playerBinding.chapterLL.isVisible = chapters.isNotEmpty()
// return if chapters are empty to avoid crashes
if (chapters.isEmpty() || _binding == null) return
@ -1218,9 +1198,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// change the chapter name textView text to the chapterName
if (chapterName != playerBinding.chapterName.text) {
playerBinding.chapterName.text = chapterName
// update the selected item
val chaptersAdapter = binding.chaptersRecView.adapter as ChaptersAdapter
chaptersAdapter.updateSelectedPosition(chapterIndex)
}
}

View File

@ -0,0 +1,63 @@
package com.github.libretube.ui.sheets
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.media3.exoplayer.ExoPlayer
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.databinding.BottomSheetBinding
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.ThemeHelper
import com.github.libretube.ui.adapters.ChaptersAdapter
class ChaptersBottomSheet(
private val chapters: List<ChapterSegment>,
private val exoPlayer: ExoPlayer
): ExpandedBottomSheet() {
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?) {
binding.optionsRecycler.layoutManager = LinearLayoutManager(context)
binding.optionsRecycler.adapter = ChaptersAdapter(chapters, exoPlayer)
binding.bottomSheetTitle.text = context?.getString(R.string.chapters)
binding.bottomSheetTitleLayout.isVisible = true
val handler = Handler(Looper.getMainLooper())
val highlightColor =
ThemeHelper.getThemeColor(requireContext(), android.R.attr.colorControlHighlight)
val updatePosition = Runnable {
// scroll to the current playing index in the chapter
val currentPosition =
PlayerHelper.getCurrentChapterIndex(exoPlayer, chapters) ?: return@Runnable
binding.optionsRecycler.smoothScrollToPosition(currentPosition)
val children = binding.optionsRecycler.children.toList()
// reset the background colors of all chapters
children.forEach { it.setBackgroundColor(Color.TRANSPARENT) }
// highlight the current chapter
children.getOrNull(currentPosition)?.setBackgroundColor(highlightColor)
}
updatePosition.run()
handler.postDelayed(updatePosition, 200)
}
}

View File

@ -16,7 +16,7 @@ import com.github.libretube.databinding.CommentsSheetBinding
import com.github.libretube.ui.fragments.CommentsMainFragment
import com.github.libretube.ui.models.CommentsViewModel
class CommentsSheet : ExpandedBottomSheet() {
class CommentsSheet : UndimmedBottomSheet() {
lateinit var binding: CommentsSheetBinding
private val commentsViewModel: CommentsViewModel by activityViewModels()
@ -82,41 +82,4 @@ class CommentsSheet : ExpandedBottomSheet() {
super.onDismiss(dialog)
commentsViewModel.commentsSheetDismiss = null
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
// BottomSheetDialogFragment passthrough user outside touch event
dialog.setOnShowListener {
dialog.findViewById<View>(com.google.android.material.R.id.touch_outside)?.apply {
setOnTouchListener { v, event ->
event.setLocation(event.rawX - v.x, event.rawY - v.y)
activity?.dispatchTouchEvent(event)
v.performClick()
false
}
}
}
dialog.apply {
setOnKeyListener { _, keyCode, _ ->
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (childFragmentManager.backStackEntryCount > 0) {
childFragmentManager.popBackStack()
return@setOnKeyListener true
}
}
return@setOnKeyListener false
}
window?.let {
it.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
}
setCanceledOnTouchOutside(false)
}
return dialog
}
}

View File

@ -0,0 +1,49 @@
package com.github.libretube.ui.sheets
import android.app.Dialog
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
/**
* A bottom sheet that allows touches on its top/background
*/
open class UndimmedBottomSheet: ExpandedBottomSheet() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
// BottomSheetDialogFragment passthrough user outside touch event
dialog.setOnShowListener {
dialog.findViewById<View>(com.google.android.material.R.id.touch_outside)?.apply {
setOnTouchListener { v, event ->
event.setLocation(event.rawX - v.x, event.rawY - v.y)
activity?.dispatchTouchEvent(event)
v.performClick()
false
}
}
}
dialog.apply {
setOnKeyListener { _, keyCode, _ ->
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (childFragmentManager.backStackEntryCount > 0) {
childFragmentManager.popBackStack()
return@setOnKeyListener true
}
}
return@setOnKeyListener false
}
window?.let {
it.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
}
setCanceledOnTouchOutside(false)
}
return dialog
}
}

View File

@ -1,6 +1,6 @@
package com.github.libretube.ui.viewholders
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.ChapterColumnBinding
import com.github.libretube.databinding.ChaptersRowBinding
class ChaptersViewHolder(val binding: ChapterColumnBinding) : RecyclerView.ViewHolder(binding.root)
class ChaptersViewHolder(val binding: ChaptersRowBinding) : RecyclerView.ViewHolder(binding.root)

View File

@ -23,6 +23,27 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:id="@+id/bottom_sheet_title_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="16dp"
android:visibility="gone">
<TextView
android:id="@+id/bottom_sheet_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:textSize="27sp" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/options_recycler"
android:layout_width="match_parent"

View File

@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView 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="wrap_content"
android:layout_height="match_parent"
android:layout_marginHorizontal="1dp"
android:background="?attr/selectableItemBackground"
android:backgroundTint="@android:color/transparent"
app:strokeWidth="0dp">
<LinearLayout
android:id="@+id/chapterLL"
android:layout_width="100dp"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="5dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="55dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/chapter_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:shapeAppearanceOverlay="@style/RoundedImageView"
tools:src="@tools:sample/backgrounds/scenic" />
<androidx.cardview.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="5dp"
android:layout_marginBottom="5dp"
app:cardBackgroundColor="?colorPrimary"
app:cardCornerRadius="4dp"
app:cardElevation="0dp">
<TextView
android:id="@+id/timeStamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="3dp"
android:paddingVertical="1dp"
android:textColor="?colorOnPrimary"
android:textSize="10sp"
tools:ignore="SmallSp"
tools:text="05:36" />
</androidx.cardview.widget.CardView>
</FrameLayout>
<TextView
android:id="@+id/chapter_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="3"
android:textSize="13sp"
tools:text="Title" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,78 @@
<?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="?attr/selectableItemBackground"
android:backgroundTint="@android:color/transparent"
android:orientation="horizontal"
android:paddingHorizontal="10dp"
android:paddingVertical="5dp">
<FrameLayout
android:layout_width="100dp"
android:layout_height="55dp"
android:layout_gravity="center">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/chapter_image"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:shapeAppearanceOverlay="@style/RoundedImageView"
tools:src="@tools:sample/backgrounds/scenic" />
<androidx.cardview.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="5dp"
android:layout_marginBottom="5dp"
app:cardBackgroundColor="?colorPrimary"
app:cardCornerRadius="4dp"
app:cardElevation="0dp">
<TextView
android:id="@+id/timeStamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="3dp"
android:paddingVertical="1dp"
android:textColor="?colorOnPrimary"
android:textSize="10sp"
tools:ignore="SmallSp"
tools:text="05:36" />
</androidx.cardview.widget.CardView>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:id="@+id/chapter_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="3"
tools:text="Chapter title" />
<TextView
android:id="@+id/duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="3"
android:textSize="12sp"
tools:text="Duration: 5s" />
</LinearLayout>
</LinearLayout>

View File

@ -107,15 +107,6 @@
android:orientation="vertical"
android:visibility="gone">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chapters_recView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="3dp"
android:layout_marginTop="20dp"
android:nestedScrollingEnabled="false"
android:visibility="gone" />
<TextView
android:id="@+id/player_description"
android:layout_width="match_parent"

View File

@ -438,6 +438,7 @@
<string name="visibility_unlisted">Unlisted</string>
<string name="sort_by">Sort by</string>
<string name="uploader_name">Uploader name</string>
<string name="duration_span">Duration: %1$s</string>
<!-- Backup & Restore Settings -->
<string name="import_subscriptions_from">Import subscriptions from</string>