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 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
*/ */

View File

@ -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()
} }

View File

@ -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 = {

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,34 +1,39 @@
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
CoroutineScope(Dispatchers.IO).launch {
processPreview(position) processPreview(position)
} }
}
/** /**
* Show a preview of the scrubber position * Show a preview of the scrubber 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)
// 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) 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)
.target {
if (!scrubInProgress) return@target
val frame = BitmapUtil.cutBitmapFromPreviewFrame(it.toBitmap(), previewFrame)
playerBinding.seekbarPreviewImage.setImageBitmap(frame) playerBinding.seekbarPreviewImage.setImageBitmap(frame)
playerBinding.seekbarPreview.visibility = View.VISIBLE playerBinding.seekbarPreview.isVisible = true
updatePreviewX(position)
} }
.build()
ImageHelper.imageLoader.enqueue(request)
} }
/** /**

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
}
}