Merge pull request #2416 from Bnyro/seekbar-preview

Seekbar preview
This commit is contained in:
Bnyro 2022-12-17 11:31:36 +01:00 committed by GitHub
commit a0ad5a1837
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 256 additions and 113 deletions

View File

@ -0,0 +1,9 @@
package com.github.libretube.obj
data class PreviewFrame(
val previewUrl: String,
val positionX: Int,
val positionY: Int,
val framesPerPageX: Int,
val framesPerPageY: Int
)

View File

@ -86,6 +86,7 @@ import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayerHelper
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
import com.github.libretube.util.SeekbarPreviewListener
import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultLoadControl import com.google.android.exoplayer2.DefaultLoadControl
@ -645,7 +646,8 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
// set media sources for the player // set media sources for the player
setResolutionAndSubtitles() setResolutionAndSubtitles()
prepareExoPlayerView() prepareExoPlayerView()
initializePlayerView(streams) initializePlayerView()
setupSeekbarPreview()
if (!isLive) seekToWatchPosition() if (!isLive) seekToWatchPosition()
exoPlayer.prepare() exoPlayer.prepare()
if (!PreferenceHelper.getBoolean(PreferenceKeys.DATA_SAVER_MODE, false)) exoPlayer.play() if (!PreferenceHelper.getBoolean(PreferenceKeys.DATA_SAVER_MODE, false)) exoPlayer.play()
@ -798,7 +800,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
private fun initializePlayerView(response: Streams) { private fun initializePlayerView() {
// initialize the player view actions // initialize the player view actions
binding.player.initialize( binding.player.initialize(
this, this,
@ -809,36 +811,36 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
binding.apply { binding.apply {
playerViewsInfo.text = playerViewsInfo.text =
context?.getString(R.string.views, response.views.formatShort()) + context?.getString(R.string.views, streams.views.formatShort()) +
if (!isLive) TextUtils.SEPARATOR + response.uploadDate else "" if (!isLive) TextUtils.SEPARATOR + streams.uploadDate else ""
textLike.text = response.likes.formatShort() textLike.text = streams.likes.formatShort()
textDislike.text = response.dislikes.formatShort() textDislike.text = streams.dislikes.formatShort()
ImageHelper.loadImage(response.uploaderAvatar, binding.playerChannelImage) ImageHelper.loadImage(streams.uploaderAvatar, binding.playerChannelImage)
playerChannelName.text = response.uploader playerChannelName.text = streams.uploader
titleTextView.text = response.title titleTextView.text = streams.title
playerTitle.text = response.title playerTitle.text = streams.title
playerDescription.text = response.description playerDescription.text = streams.description
playerChannelSubCount.text = context?.getString( playerChannelSubCount.text = context?.getString(
R.string.subscribers, R.string.subscribers,
response.uploaderSubscriberCount?.formatShort() streams.uploaderSubscriberCount?.formatShort()
) )
} }
// duration that's not greater than 0 indicates that the video is live // duration that's not greater than 0 indicates that the video is live
if (response.duration!! <= 0) { if (streams.duration!! <= 0) {
isLive = true isLive = true
handleLiveVideo() handleLiveVideo()
} }
playerBinding.exoTitle.text = response.title playerBinding.exoTitle.text = streams.title
// init the chapters recyclerview // init the chapters recyclerview
if (response.chapters != null) { if (streams.chapters != null) {
chapters = response.chapters chapters = streams.chapters.orEmpty()
initializeChapters() initializeChapters()
} }
@ -922,7 +924,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
}) })
binding.relPlayerDownload.setOnClickListener { binding.relPlayerDownload.setOnClickListener {
if (response.duration <= 0) { if (streams.duration!! <= 0) {
Toast.makeText(context, R.string.cannotDownload, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.cannotDownload, Toast.LENGTH_SHORT).show()
} else if (!DownloadService.IS_DOWNLOAD_RUNNING) { } else if (!DownloadService.IS_DOWNLOAD_RUNNING) {
val newFragment = DownloadDialog(videoId!!) val newFragment = DownloadDialog(videoId!!)
@ -933,7 +935,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
} }
if (response.hls != null) { if (streams.hls != null) {
binding.relPlayerPip.setOnClickListener { binding.relPlayerPip.setOnClickListener {
if (SDK_INT < Build.VERSION_CODES.O) return@setOnClickListener if (SDK_INT < Build.VERSION_CODES.O) return@setOnClickListener
try { try {
@ -943,9 +945,9 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
} }
} }
initializeRelatedVideos(response.relatedStreams) initializeRelatedVideos(streams.relatedStreams)
// set video description // set video description
val description = response.description!! val description = streams.description!!
// detect whether the description is html formatted // detect whether the description is html formatted
if (description.contains("<") && description.contains(">")) { if (description.contains("<") && description.contains(">")) {
@ -958,7 +960,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
binding.playerChannel.setOnClickListener { binding.playerChannel.setOnClickListener {
val activity = view?.context as MainActivity val activity = view?.context as MainActivity
val bundle = bundleOf(IntentData.channelId to response.uploaderUrl) val bundle = bundleOf(IntentData.channelId to streams.uploaderUrl)
activity.navController.navigate(R.id.channelFragment, bundle) activity.navController.navigate(R.id.channelFragment, bundle)
activity.binding.mainMotionLayout.transitionToEnd() activity.binding.mainMotionLayout.transitionToEnd()
binding.playerMotionLayout.transitionToEnd() binding.playerMotionLayout.transitionToEnd()
@ -966,8 +968,8 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
// update the subscribed state // update the subscribed state
binding.playerSubscribe.setupSubscriptionButton( binding.playerSubscribe.setupSubscriptionButton(
streams.uploaderUrl?.toID(), this.streams.uploaderUrl?.toID(),
streams.uploader this.streams.uploader
) )
binding.relPlayerSave.setOnClickListener { binding.relPlayerSave.setOnClickListener {
@ -1446,6 +1448,17 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
.build() .build()
private fun setupSeekbarPreview() {
playerBinding.seekbarPreview.visibility = View.GONE
playerBinding.exoProgress.addListener(
SeekbarPreviewListener(
streams.previewFrames.orEmpty(),
playerBinding.seekbarPreview,
streams.duration!! * 1000
)
)
}
private fun shouldStartPiP(): Boolean { private fun shouldStartPiP(): Boolean {
if (!PlayerHelper.pipEnabled || SDK_INT >= Build.VERSION_CODES.S) { if (!PlayerHelper.pipEnabled || SDK_INT >= Build.VERSION_CODES.S) {
return false return false

View File

@ -0,0 +1,110 @@
package com.github.libretube.util
import android.graphics.Bitmap
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap
import coil.request.ImageRequest
import com.github.libretube.api.obj.PreviewFrames
import com.github.libretube.obj.PreviewFrame
import com.google.android.exoplayer2.ui.TimeBar
class SeekbarPreviewListener(
private val previewFrames: List<PreviewFrames>,
private val previewIv: ImageView,
private val duration: Long
) : TimeBar.OnScrubListener {
private var moving = false
override fun onScrubStart(timeBar: TimeBar, position: Long) {}
/**
* Show a preview of the scrubber position
*/
override fun onScrubMove(timeBar: TimeBar, position: Long) {
moving = true
val preview = getPreviewFrame(position) ?: return
updatePreviewX(position)
val request = ImageRequest.Builder(previewIv.context)
.data(preview.previewUrl)
.target {
if (!moving) return@target
val bitmap = it.toBitmap()
val heightPerFrame = bitmap.height / preview.framesPerPageY
val widthPerFrame = bitmap.width / preview.framesPerPageX
val frame = Bitmap.createBitmap(
bitmap,
preview.positionX * widthPerFrame,
preview.positionY * heightPerFrame,
widthPerFrame,
heightPerFrame
)
previewIv.setImageBitmap(frame)
previewIv.visibility = View.VISIBLE
}
.build()
ImageHelper.imageLoader.enqueue(request)
}
/**
* Hide the seekbar preview with a short delay
*/
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
moving = false
previewIv.animate()
.alpha(0f)
.translationYBy(30f)
.setDuration(200)
.withEndAction {
previewIv.visibility = View.GONE
previewIv.translationY = previewIv.translationY - 30f
previewIv.alpha = 1f
}
.start()
}
/**
* Get the preview frame according to the current position
*/
private fun getPreviewFrame(position: Long): PreviewFrame? {
var startPosition: Long = 0
val frames = previewFrames.sortedBy { it.frameHeight }.lastOrNull()
frames?.urls?.forEach { url ->
for (y in 0 until frames.framesPerPageY!!) {
for (x in 0 until frames.framesPerPageX!!) {
val endPosition = startPosition + frames.durationPerFrame!!.toLong()
if (position in startPosition until endPosition) {
return PreviewFrame(url, x, y, frames.framesPerPageX, frames.framesPerPageY)
}
startPosition = endPosition
}
}
}
return null
}
private fun updatePreviewX(position: Long) {
val params = previewIv.layoutParams as MarginLayoutParams
val parentWidth = (previewIv.parent as View).width
// calculate the center-offset of the preview image view
val offset = parentWidth * (position.toFloat() / duration.toFloat()) - previewIv.width / 2
// normalize the offset to keep a minimum distance at left and right
params.marginStart = normalizeOffset(
offset.toInt(),
MIN_PADDING,
parentWidth - MIN_PADDING - previewIv.width
)
previewIv.layoutParams = params
}
private fun normalizeOffset(offset: Int, min: Int, max: Int): Int {
return maxOf(min, minOf(max, offset))
}
companion object {
const val MIN_PADDING = 20
}
}

View File

@ -95,6 +95,96 @@
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@id/exo_center_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@android:color/transparent"
android:gravity="center"
android:padding="20dp">
<ImageView
android:id="@+id/skip_prev"
style="@style/PlayerControlCenter"
android:background="?android:selectableItemBackgroundBorderless"
android:src="@drawable/ic_prev"
android:visibility="invisible"
app:tint="@android:color/white" />
<FrameLayout
android:id="@+id/rewindBTN"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleX="0.8"
android:scaleY="0.8"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_rewind"
app:tint="@android:color/white" />
<TextView
android:id="@+id/rewindTV"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
</FrameLayout>
<ImageButton
android:id="@+id/playPauseBTN"
style="@style/ExoStyledControls.Button.Center.PlayPause"
android:layout_marginHorizontal="10dp"
android:background="?android:selectableItemBackgroundBorderless"
app:tint="@android:color/white" />
<FrameLayout
android:id="@+id/forwardBTN"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleX="0.8"
android:scaleY="0.8"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_forward"
app:tint="@android:color/white" />
<TextView
android:id="@+id/forwardTV"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
</FrameLayout>
<ImageView
android:id="@+id/skip_next"
style="@style/PlayerControlCenter"
android:background="?android:selectableItemBackgroundBorderless"
android:src="@drawable/ic_next"
android:visibility="invisible"
app:tint="@android:color/white" />
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@id/exo_bottom_bar" android:id="@id/exo_bottom_bar"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -103,6 +193,17 @@
android:layout_marginTop="10dp" android:layout_marginTop="10dp"
android:orientation="vertical"> android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/seekbar_preview"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:translationY="27dp"
android:translationZ="1dp"
android:visibility="gone"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Small" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -231,94 +332,4 @@
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@id/exo_center_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@android:color/transparent"
android:gravity="center"
android:padding="20dp">
<ImageView
android:id="@+id/skip_prev"
style="@style/PlayerControlCenter"
android:background="?android:selectableItemBackgroundBorderless"
android:src="@drawable/ic_prev"
android:visibility="invisible"
app:tint="@android:color/white" />
<FrameLayout
android:id="@+id/rewindBTN"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleX="0.8"
android:scaleY="0.8"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_rewind"
app:tint="@android:color/white" />
<TextView
android:id="@+id/rewindTV"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
</FrameLayout>
<ImageButton
android:id="@+id/playPauseBTN"
style="@style/ExoStyledControls.Button.Center.PlayPause"
android:layout_marginHorizontal="10dp"
android:background="?android:selectableItemBackgroundBorderless"
app:tint="@android:color/white" />
<FrameLayout
android:id="@+id/forwardBTN"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleX="0.8"
android:scaleY="0.8"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_forward"
app:tint="@android:color/white" />
<TextView
android:id="@+id/forwardTV"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
</FrameLayout>
<ImageView
android:id="@+id/skip_next"
style="@style/PlayerControlCenter"
android:background="?android:selectableItemBackgroundBorderless"
android:src="@drawable/ic_next"
android:visibility="invisible"
app:tint="@android:color/white" />
</LinearLayout>
</merge> </merge>