Merge pull request #4337 from Bnyro/master

feat: seekbar preview for downloaded videos
This commit is contained in:
Bnyro 2023-07-31 14:57:59 +02:00 committed by GitHub
commit ef99780d30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 147 additions and 74 deletions

View File

@ -457,28 +457,6 @@ object PlayerHelper {
return this
}
/**
* Get the preview frame according to the current position
*/
fun getPreviewFrame(previewFrames: List<PreviewFrames>, position: Long): PreviewFrame? {
var startPosition: Long = 0
// get the frames with the best quality
val frames = previewFrames.maxByOrNull { it.frameHeight }
frames?.urls?.forEach { url ->
// iterate over all available positions and find the one matching the current position
for (y in 0 until frames.framesPerPageY) {
for (x in 0 until frames.framesPerPageX) {
val endPosition = startPosition + frames.durationPerFrame
if (position in startPosition until endPosition) {
return PreviewFrame(url, x, y, frames.frameWidth, frames.frameHeight)
}
startPosition = endPosition
}
}
}
return null
}
/**
* get the categories for sponsorBlock
*/

View File

@ -33,7 +33,10 @@ import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.loadPlaybackParams
import com.github.libretube.helpers.WindowHelper
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.interfaces.TimeFrameReceiver
import com.github.libretube.ui.listeners.SeekbarPreviewListener
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.util.OfflineTimeFrameReceiver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -45,6 +48,7 @@ class OfflinePlayerActivity : BaseActivity() {
private lateinit var player: ExoPlayer
private lateinit var playerView: PlayerView
private lateinit var trackSelector: DefaultTrackSelector
private var timeFrameReceiver: TimeFrameReceiver? = null
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
private val playerViewModel: PlayerViewModel by viewModels()
@ -89,6 +93,20 @@ class OfflinePlayerActivity : BaseActivity() {
player.duration / 1000
)
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
// setup seekbar preview
if (playbackState == Player.STATE_READY) {
binding.player.binding.exoProgress.addListener(
SeekbarPreviewListener(
timeFrameReceiver ?: return,
binding.player.binding,
player.duration
)
)
}
}
})
}
.loadPlaybackParams()
@ -134,6 +152,10 @@ class OfflinePlayerActivity : BaseActivity() {
setPreferredTextLanguage("en")
}
timeFrameReceiver = video?.path?.let {
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it)
}
player.prepare()
player.play()
}

View File

@ -24,6 +24,7 @@ import android.widget.Toast
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.motion.widget.TransitionAdapter
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.core.os.postDelayed
@ -102,6 +103,7 @@ import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.dialogs.StatsDialog
import com.github.libretube.ui.extensions.setupSubscriptionButton
import com.github.libretube.ui.interfaces.OnlinePlayerOptions
import com.github.libretube.ui.interfaces.TimeFrameReceiver
import com.github.libretube.ui.listeners.SeekbarPreviewListener
import com.github.libretube.ui.models.CommentsViewModel
import com.github.libretube.ui.models.PlayerViewModel
@ -111,6 +113,7 @@ import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.util.HtmlParser
import com.github.libretube.util.LinkHandler
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.OnlineTimeFrameReceiver
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.TextUtils
import com.github.libretube.util.TextUtils.toTimeInSeconds
@ -1183,15 +1186,14 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
private suspend fun initializeHighlight(highlight: Segment) {
val frame =
PlayerHelper.getPreviewFrame(streams.previewFrames, exoPlayer.currentPosition) ?: return
val drawable = withContext(Dispatchers.IO) {
ImageHelper.getImage(requireContext(), frame.previewUrl)
}.drawable ?: return
val frameReceiver = OnlineTimeFrameReceiver(requireContext(), streams.previewFrames)
val frame = withContext(Dispatchers.IO) {
frameReceiver.getFrameAtTime(highlight.segmentStartAndEnd.first.toLong() * 1000)
}
val highlightChapter = ChapterSegment(
title = getString(R.string.chapters_videoHighlight),
start = highlight.segmentStartAndEnd.first.toLong(),
drawable = drawable
drawable = frame?.toDrawable(requireContext().resources)
)
chapters.add(highlightChapter)
chapters.sortBy { it.start }
@ -1598,7 +1600,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
playerBinding.seekbarPreview.visibility = View.GONE
playerBinding.exoProgress.addListener(
SeekbarPreviewListener(
streams.previewFrames,
OnlineTimeFrameReceiver(requireContext(), streams.previewFrames),
playerBinding,
streams.duration * 1000,
onScrub = {

View File

@ -0,0 +1,7 @@
package com.github.libretube.ui.interfaces
import android.graphics.Bitmap
abstract class TimeFrameReceiver {
abstract suspend fun getFrameAtTime(position: Long): Bitmap?
}

View File

@ -1,33 +1,38 @@
package com.github.libretube.ui.listeners
import android.text.format.DateUtils
import android.util.Log
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.TimeBar
import coil.request.ImageRequest
import com.github.libretube.api.obj.PreviewFrames
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.util.BitmapUtil
import com.github.libretube.ui.interfaces.TimeFrameReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.absoluteValue
@UnstableApi
class SeekbarPreviewListener(
private val previewFrames: List<PreviewFrames>,
private val timeFrameReceiver: TimeFrameReceiver,
private val playerBinding: ExoStyledPlayerControlViewBinding,
private val duration: Long,
private val onScrub: (position: Long) -> Unit,
private val onScrubEnd: (position: Long) -> Unit
private val onScrub: (position: Long) -> Unit = {},
private val onScrubEnd: (position: Long) -> Unit = {}
) : TimeBar.OnScrubListener {
private var scrubInProgress = false
private var lastPreviewPosition = Long.MAX_VALUE
override fun onScrubStart(timeBar: TimeBar, position: Long) {
scrubInProgress = true
processPreview(position)
CoroutineScope(Dispatchers.IO).launch {
processPreview(position)
}
}
/**
@ -37,7 +42,16 @@ class SeekbarPreviewListener(
scrubInProgress = true
playerBinding.seekbarPreviewPosition.text = DateUtils.formatElapsedTime(position / 1000)
processPreview(position)
// minimum of five seconds of additional seeking in order to show a preview
if ((lastPreviewPosition - position).absoluteValue < 5000) {
updatePreviewX(position)
return
}
CoroutineScope(Dispatchers.IO).launch {
processPreview(position)
}
runCatching {
onScrub.invoke(position)
@ -67,23 +81,17 @@ class SeekbarPreviewListener(
/**
* Make a request to get the image frame and update its position
*/
private fun processPreview(position: Long) {
val previewFrame = PlayerHelper.getPreviewFrame(previewFrames, position) ?: return
private suspend fun processPreview(position: Long) {
lastPreviewPosition = position
// update the offset of the preview image view
updatePreviewX(position)
val frame = timeFrameReceiver.getFrameAtTime(position)
if (!scrubInProgress) return
val request = ImageRequest.Builder(playerBinding.seekbarPreview.context)
.data(previewFrame.previewUrl)
.target {
if (!scrubInProgress) return@target
val frame = BitmapUtil.cutBitmapFromPreviewFrame(it.toBitmap(), previewFrame)
playerBinding.seekbarPreviewImage.setImageBitmap(frame)
playerBinding.seekbarPreview.visibility = View.VISIBLE
}
.build()
ImageHelper.imageLoader.enqueue(request)
withContext(Dispatchers.Main) {
playerBinding.seekbarPreviewImage.setImageBitmap(frame)
playerBinding.seekbarPreview.isVisible = true
updatePreviewX(position)
}
}
/**

View File

@ -1,19 +0,0 @@
package com.github.libretube.util
import android.graphics.Bitmap
import com.github.libretube.obj.PreviewFrame
object BitmapUtil {
/**
* Cut off a new bitmap from the image that contains multiple preview thumbnails
*/
fun cutBitmapFromPreviewFrame(bitmap: Bitmap, previewFrame: PreviewFrame): Bitmap {
return Bitmap.createBitmap(
bitmap,
previewFrame.positionX * previewFrame.frameWidth,
previewFrame.positionY * previewFrame.frameHeight,
previewFrame.frameWidth,
previewFrame.frameHeight
)
}
}

View File

@ -0,0 +1,20 @@
package com.github.libretube.util
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.ui.interfaces.TimeFrameReceiver
import java.nio.file.Path
class OfflineTimeFrameReceiver(
private val context: Context,
private val videoSource: Path
): TimeFrameReceiver() {
private val metadataRetriever = MediaMetadataRetriever().apply {
setDataSource(context, videoSource.toAndroidUri())
}
override suspend fun getFrameAtTime(position: Long): Bitmap? {
return metadataRetriever.getFrameAtTime(position * 1000)
}
}

View File

@ -0,0 +1,55 @@
package com.github.libretube.util
import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import com.github.libretube.api.obj.PreviewFrames
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.obj.PreviewFrame
import com.github.libretube.ui.interfaces.TimeFrameReceiver
class OnlineTimeFrameReceiver(
private val context: Context,
private val previewFrames: List<PreviewFrames>
): TimeFrameReceiver() {
override suspend fun getFrameAtTime(position: Long): Bitmap? {
val previewFrame = getPreviewFrame(previewFrames, position) ?: return null
val drawable = ImageHelper.getImage(context, previewFrame.previewUrl).drawable ?: return null
return cutBitmapFromPreviewFrame(drawable.toBitmap(), previewFrame)
}
/**
* Cut off a new bitmap from the image that contains multiple preview thumbnails
*/
private fun cutBitmapFromPreviewFrame(bitmap: Bitmap, previewFrame: PreviewFrame): Bitmap {
return Bitmap.createBitmap(
bitmap,
previewFrame.positionX * previewFrame.frameWidth,
previewFrame.positionY * previewFrame.frameHeight,
previewFrame.frameWidth,
previewFrame.frameHeight
)
}
/**
* Get the preview frame according to the current position
*/
private fun getPreviewFrame(previewFrames: List<PreviewFrames>, position: Long): PreviewFrame? {
var startPosition = 0L
// get the frames with the best quality
val frames = previewFrames.maxByOrNull { it.frameHeight }
frames?.urls?.forEach { url ->
// iterate over all available positions and find the one matching the current position
for (y in 0 until frames.framesPerPageY) {
for (x in 0 until frames.framesPerPageX) {
val endPosition = startPosition + frames.durationPerFrame
if (position in startPosition until endPosition) {
return PreviewFrame(url, x, y, frames.frameWidth, frames.frameHeight)
}
startPosition = endPosition
}
}
}
return null
}
}