mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 00:10:32 +05:30
Merge pull request #4337 from Bnyro/master
feat: seekbar preview for downloaded videos
This commit is contained in:
commit
ef99780d30
@ -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
|
||||
*/
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -0,0 +1,7 @@
|
||||
package com.github.libretube.ui.interfaces
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
abstract class TimeFrameReceiver {
|
||||
abstract suspend fun getFrameAtTime(position: Long): Bitmap?
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user