diff --git a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt index 244f909df..d4c60968b 100644 --- a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt @@ -457,28 +457,6 @@ object PlayerHelper { return this } - /** - * Get the preview frame according to the current position - */ - fun getPreviewFrame(previewFrames: List, 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 */ diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt index f811bc1b0..08c3c0cb7 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt @@ -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() } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index e6c8a075e..327ce85bb 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -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 = { diff --git a/app/src/main/java/com/github/libretube/ui/interfaces/TimeFrameReceiver.kt b/app/src/main/java/com/github/libretube/ui/interfaces/TimeFrameReceiver.kt new file mode 100644 index 000000000..3400a0582 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/interfaces/TimeFrameReceiver.kt @@ -0,0 +1,7 @@ +package com.github.libretube.ui.interfaces + +import android.graphics.Bitmap + +abstract class TimeFrameReceiver { + abstract suspend fun getFrameAtTime(position: Long): Bitmap? +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt b/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt index 34c9dd10e..cb29aeed1 100644 --- a/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt +++ b/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt @@ -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, + 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) + } } /** diff --git a/app/src/main/java/com/github/libretube/util/BitmapUtil.kt b/app/src/main/java/com/github/libretube/util/BitmapUtil.kt deleted file mode 100644 index 5f325c7f7..000000000 --- a/app/src/main/java/com/github/libretube/util/BitmapUtil.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/app/src/main/java/com/github/libretube/util/OfflineTimeFrameReceiver.kt b/app/src/main/java/com/github/libretube/util/OfflineTimeFrameReceiver.kt new file mode 100644 index 000000000..536c5e83b --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/OfflineTimeFrameReceiver.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/util/OnlineTimeFrameReceiver.kt b/app/src/main/java/com/github/libretube/util/OnlineTimeFrameReceiver.kt new file mode 100644 index 000000000..061975fbc --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/OnlineTimeFrameReceiver.kt @@ -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 +): 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, 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 + } +} \ No newline at end of file