mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 08:20: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
|
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
|
* 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.PlayerHelper.loadPlaybackParams
|
||||||
import com.github.libretube.helpers.WindowHelper
|
import com.github.libretube.helpers.WindowHelper
|
||||||
import com.github.libretube.ui.base.BaseActivity
|
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.ui.models.PlayerViewModel
|
||||||
|
import com.github.libretube.util.OfflineTimeFrameReceiver
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -45,6 +48,7 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
private lateinit var playerView: PlayerView
|
private lateinit var playerView: PlayerView
|
||||||
private lateinit var trackSelector: DefaultTrackSelector
|
private lateinit var trackSelector: DefaultTrackSelector
|
||||||
|
private var timeFrameReceiver: TimeFrameReceiver? = null
|
||||||
|
|
||||||
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
|
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
|
||||||
private val playerViewModel: PlayerViewModel by viewModels()
|
private val playerViewModel: PlayerViewModel by viewModels()
|
||||||
@ -89,6 +93,20 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
player.duration / 1000
|
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()
|
.loadPlaybackParams()
|
||||||
@ -134,6 +152,10 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
setPreferredTextLanguage("en")
|
setPreferredTextLanguage("en")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
timeFrameReceiver = video?.path?.let {
|
||||||
|
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it)
|
||||||
|
}
|
||||||
|
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ import android.widget.Toast
|
|||||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||||
import androidx.constraintlayout.motion.widget.TransitionAdapter
|
import androidx.constraintlayout.motion.widget.TransitionAdapter
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.core.os.postDelayed
|
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.dialogs.StatsDialog
|
||||||
import com.github.libretube.ui.extensions.setupSubscriptionButton
|
import com.github.libretube.ui.extensions.setupSubscriptionButton
|
||||||
import com.github.libretube.ui.interfaces.OnlinePlayerOptions
|
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.listeners.SeekbarPreviewListener
|
||||||
import com.github.libretube.ui.models.CommentsViewModel
|
import com.github.libretube.ui.models.CommentsViewModel
|
||||||
import com.github.libretube.ui.models.PlayerViewModel
|
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.HtmlParser
|
||||||
import com.github.libretube.util.LinkHandler
|
import com.github.libretube.util.LinkHandler
|
||||||
import com.github.libretube.util.NowPlayingNotification
|
import com.github.libretube.util.NowPlayingNotification
|
||||||
|
import com.github.libretube.util.OnlineTimeFrameReceiver
|
||||||
import com.github.libretube.util.PlayingQueue
|
import com.github.libretube.util.PlayingQueue
|
||||||
import com.github.libretube.util.TextUtils
|
import com.github.libretube.util.TextUtils
|
||||||
import com.github.libretube.util.TextUtils.toTimeInSeconds
|
import com.github.libretube.util.TextUtils.toTimeInSeconds
|
||||||
@ -1183,15 +1186,14 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun initializeHighlight(highlight: Segment) {
|
private suspend fun initializeHighlight(highlight: Segment) {
|
||||||
val frame =
|
val frameReceiver = OnlineTimeFrameReceiver(requireContext(), streams.previewFrames)
|
||||||
PlayerHelper.getPreviewFrame(streams.previewFrames, exoPlayer.currentPosition) ?: return
|
val frame = withContext(Dispatchers.IO) {
|
||||||
val drawable = withContext(Dispatchers.IO) {
|
frameReceiver.getFrameAtTime(highlight.segmentStartAndEnd.first.toLong() * 1000)
|
||||||
ImageHelper.getImage(requireContext(), frame.previewUrl)
|
}
|
||||||
}.drawable ?: return
|
|
||||||
val highlightChapter = ChapterSegment(
|
val highlightChapter = ChapterSegment(
|
||||||
title = getString(R.string.chapters_videoHighlight),
|
title = getString(R.string.chapters_videoHighlight),
|
||||||
start = highlight.segmentStartAndEnd.first.toLong(),
|
start = highlight.segmentStartAndEnd.first.toLong(),
|
||||||
drawable = drawable
|
drawable = frame?.toDrawable(requireContext().resources)
|
||||||
)
|
)
|
||||||
chapters.add(highlightChapter)
|
chapters.add(highlightChapter)
|
||||||
chapters.sortBy { it.start }
|
chapters.sortBy { it.start }
|
||||||
@ -1598,7 +1600,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
playerBinding.seekbarPreview.visibility = View.GONE
|
playerBinding.seekbarPreview.visibility = View.GONE
|
||||||
playerBinding.exoProgress.addListener(
|
playerBinding.exoProgress.addListener(
|
||||||
SeekbarPreviewListener(
|
SeekbarPreviewListener(
|
||||||
streams.previewFrames,
|
OnlineTimeFrameReceiver(requireContext(), streams.previewFrames),
|
||||||
playerBinding,
|
playerBinding,
|
||||||
streams.duration * 1000,
|
streams.duration * 1000,
|
||||||
onScrub = {
|
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
|
package com.github.libretube.ui.listeners
|
||||||
|
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup.MarginLayoutParams
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.TimeBar
|
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.databinding.ExoStyledPlayerControlViewBinding
|
||||||
import com.github.libretube.helpers.ImageHelper
|
import com.github.libretube.ui.interfaces.TimeFrameReceiver
|
||||||
import com.github.libretube.helpers.PlayerHelper
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import com.github.libretube.util.BitmapUtil
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class SeekbarPreviewListener(
|
class SeekbarPreviewListener(
|
||||||
private val previewFrames: List<PreviewFrames>,
|
private val timeFrameReceiver: TimeFrameReceiver,
|
||||||
private val playerBinding: ExoStyledPlayerControlViewBinding,
|
private val playerBinding: ExoStyledPlayerControlViewBinding,
|
||||||
private val duration: Long,
|
private val duration: Long,
|
||||||
private val onScrub: (position: Long) -> Unit,
|
private val onScrub: (position: Long) -> Unit = {},
|
||||||
private val onScrubEnd: (position: Long) -> Unit
|
private val onScrubEnd: (position: Long) -> Unit = {}
|
||||||
) : TimeBar.OnScrubListener {
|
) : TimeBar.OnScrubListener {
|
||||||
private var scrubInProgress = false
|
private var scrubInProgress = false
|
||||||
|
private var lastPreviewPosition = Long.MAX_VALUE
|
||||||
|
|
||||||
override fun onScrubStart(timeBar: TimeBar, position: Long) {
|
override fun onScrubStart(timeBar: TimeBar, position: Long) {
|
||||||
scrubInProgress = true
|
scrubInProgress = true
|
||||||
|
|
||||||
processPreview(position)
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
processPreview(position)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,7 +42,16 @@ class SeekbarPreviewListener(
|
|||||||
scrubInProgress = true
|
scrubInProgress = true
|
||||||
|
|
||||||
playerBinding.seekbarPreviewPosition.text = DateUtils.formatElapsedTime(position / 1000)
|
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 {
|
runCatching {
|
||||||
onScrub.invoke(position)
|
onScrub.invoke(position)
|
||||||
@ -67,23 +81,17 @@ class SeekbarPreviewListener(
|
|||||||
/**
|
/**
|
||||||
* Make a request to get the image frame and update its position
|
* Make a request to get the image frame and update its position
|
||||||
*/
|
*/
|
||||||
private fun processPreview(position: Long) {
|
private suspend fun processPreview(position: Long) {
|
||||||
val previewFrame = PlayerHelper.getPreviewFrame(previewFrames, position) ?: return
|
lastPreviewPosition = position
|
||||||
|
|
||||||
// update the offset of the preview image view
|
val frame = timeFrameReceiver.getFrameAtTime(position)
|
||||||
updatePreviewX(position)
|
if (!scrubInProgress) return
|
||||||
|
|
||||||
val request = ImageRequest.Builder(playerBinding.seekbarPreview.context)
|
withContext(Dispatchers.Main) {
|
||||||
.data(previewFrame.previewUrl)
|
playerBinding.seekbarPreviewImage.setImageBitmap(frame)
|
||||||
.target {
|
playerBinding.seekbarPreview.isVisible = true
|
||||||
if (!scrubInProgress) return@target
|
updatePreviewX(position)
|
||||||
val frame = BitmapUtil.cutBitmapFromPreviewFrame(it.toBitmap(), previewFrame)
|
}
|
||||||
playerBinding.seekbarPreviewImage.setImageBitmap(frame)
|
|
||||||
playerBinding.seekbarPreview.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
ImageHelper.imageLoader.enqueue(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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