diff --git a/app/src/main/java/com/github/libretube/obj/PreviewFrame.kt b/app/src/main/java/com/github/libretube/obj/PreviewFrame.kt new file mode 100644 index 000000000..d3b8fb676 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/PreviewFrame.kt @@ -0,0 +1,9 @@ +package com.github.libretube.obj + +data class PreviewFrame( + val previewUrl: String, + val positionX: Int, + val positionY: Int, + val framesPerPageX: Int, + val framesPerPageY: Int +) 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 90876a342..4cbee4ebe 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 @@ -86,6 +86,7 @@ import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.SeekbarPreviewListener import com.github.libretube.util.TextUtils import com.google.android.exoplayer2.C import com.google.android.exoplayer2.DefaultLoadControl @@ -645,7 +646,8 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { // set media sources for the player setResolutionAndSubtitles() prepareExoPlayerView() - initializePlayerView(streams) + initializePlayerView() + setupSeekbarPreview() if (!isLive) seekToWatchPosition() exoPlayer.prepare() if (!PreferenceHelper.getBoolean(PreferenceKeys.DATA_SAVER_MODE, false)) exoPlayer.play() @@ -798,7 +800,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { } @SuppressLint("SetTextI18n") - private fun initializePlayerView(response: Streams) { + private fun initializePlayerView() { // initialize the player view actions binding.player.initialize( this, @@ -809,36 +811,36 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { binding.apply { playerViewsInfo.text = - context?.getString(R.string.views, response.views.formatShort()) + - if (!isLive) TextUtils.SEPARATOR + response.uploadDate else "" + context?.getString(R.string.views, streams.views.formatShort()) + + if (!isLive) TextUtils.SEPARATOR + streams.uploadDate else "" - textLike.text = response.likes.formatShort() - textDislike.text = response.dislikes.formatShort() - ImageHelper.loadImage(response.uploaderAvatar, binding.playerChannelImage) - playerChannelName.text = response.uploader + textLike.text = streams.likes.formatShort() + textDislike.text = streams.dislikes.formatShort() + ImageHelper.loadImage(streams.uploaderAvatar, binding.playerChannelImage) + playerChannelName.text = streams.uploader - titleTextView.text = response.title + titleTextView.text = streams.title - playerTitle.text = response.title - playerDescription.text = response.description + playerTitle.text = streams.title + playerDescription.text = streams.description playerChannelSubCount.text = context?.getString( R.string.subscribers, - response.uploaderSubscriberCount?.formatShort() + streams.uploaderSubscriberCount?.formatShort() ) } // duration that's not greater than 0 indicates that the video is live - if (response.duration!! <= 0) { + if (streams.duration!! <= 0) { isLive = true handleLiveVideo() } - playerBinding.exoTitle.text = response.title + playerBinding.exoTitle.text = streams.title // init the chapters recyclerview - if (response.chapters != null) { - chapters = response.chapters + if (streams.chapters != null) { + chapters = streams.chapters.orEmpty() initializeChapters() } @@ -922,7 +924,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { }) binding.relPlayerDownload.setOnClickListener { - if (response.duration <= 0) { + if (streams.duration!! <= 0) { Toast.makeText(context, R.string.cannotDownload, Toast.LENGTH_SHORT).show() } else if (!DownloadService.IS_DOWNLOAD_RUNNING) { val newFragment = DownloadDialog(videoId!!) @@ -933,7 +935,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { } } - if (response.hls != null) { + if (streams.hls != null) { binding.relPlayerPip.setOnClickListener { if (SDK_INT < Build.VERSION_CODES.O) return@setOnClickListener try { @@ -943,9 +945,9 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { } } } - initializeRelatedVideos(response.relatedStreams) + initializeRelatedVideos(streams.relatedStreams) // set video description - val description = response.description!! + val description = streams.description!! // detect whether the description is html formatted if (description.contains("<") && description.contains(">")) { @@ -958,7 +960,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { binding.playerChannel.setOnClickListener { val activity = view?.context as MainActivity - val bundle = bundleOf(IntentData.channelId to response.uploaderUrl) + val bundle = bundleOf(IntentData.channelId to streams.uploaderUrl) activity.navController.navigate(R.id.channelFragment, bundle) activity.binding.mainMotionLayout.transitionToEnd() binding.playerMotionLayout.transitionToEnd() @@ -966,8 +968,8 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { // update the subscribed state binding.playerSubscribe.setupSubscriptionButton( - streams.uploaderUrl?.toID(), - streams.uploader + this.streams.uploaderUrl?.toID(), + this.streams.uploader ) binding.relPlayerSave.setOnClickListener { @@ -1446,6 +1448,17 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { } .build() + private fun setupSeekbarPreview() { + playerBinding.seekbarPreview.visibility = View.GONE + playerBinding.exoProgress.addListener( + SeekbarPreviewListener( + streams.previewFrames.orEmpty(), + playerBinding.seekbarPreview, + streams.duration!! * 1000 + ) + ) + } + private fun shouldStartPiP(): Boolean { if (!PlayerHelper.pipEnabled || SDK_INT >= Build.VERSION_CODES.S) { return false diff --git a/app/src/main/java/com/github/libretube/util/SeekbarPreviewListener.kt b/app/src/main/java/com/github/libretube/util/SeekbarPreviewListener.kt new file mode 100644 index 000000000..7b5eab7c4 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/SeekbarPreviewListener.kt @@ -0,0 +1,110 @@ +package com.github.libretube.util + +import android.graphics.Bitmap +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.ImageView +import androidx.core.graphics.drawable.toBitmap +import coil.request.ImageRequest +import com.github.libretube.api.obj.PreviewFrames +import com.github.libretube.obj.PreviewFrame +import com.google.android.exoplayer2.ui.TimeBar + +class SeekbarPreviewListener( + private val previewFrames: List, + private val previewIv: ImageView, + private val duration: Long +) : TimeBar.OnScrubListener { + private var moving = false + + override fun onScrubStart(timeBar: TimeBar, position: Long) {} + + /** + * Show a preview of the scrubber position + */ + override fun onScrubMove(timeBar: TimeBar, position: Long) { + moving = true + val preview = getPreviewFrame(position) ?: return + updatePreviewX(position) + + val request = ImageRequest.Builder(previewIv.context) + .data(preview.previewUrl) + .target { + if (!moving) return@target + val bitmap = it.toBitmap() + val heightPerFrame = bitmap.height / preview.framesPerPageY + val widthPerFrame = bitmap.width / preview.framesPerPageX + val frame = Bitmap.createBitmap( + bitmap, + preview.positionX * widthPerFrame, + preview.positionY * heightPerFrame, + widthPerFrame, + heightPerFrame + ) + previewIv.setImageBitmap(frame) + previewIv.visibility = View.VISIBLE + } + .build() + + ImageHelper.imageLoader.enqueue(request) + } + + /** + * Hide the seekbar preview with a short delay + */ + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + moving = false + previewIv.animate() + .alpha(0f) + .translationYBy(30f) + .setDuration(200) + .withEndAction { + previewIv.visibility = View.GONE + previewIv.translationY = previewIv.translationY - 30f + previewIv.alpha = 1f + } + .start() + } + + /** + * Get the preview frame according to the current position + */ + private fun getPreviewFrame(position: Long): PreviewFrame? { + var startPosition: Long = 0 + val frames = previewFrames.sortedBy { it.frameHeight }.lastOrNull() + frames?.urls?.forEach { url -> + for (y in 0 until frames.framesPerPageY!!) { + for (x in 0 until frames.framesPerPageX!!) { + val endPosition = startPosition + frames.durationPerFrame!!.toLong() + if (position in startPosition until endPosition) { + return PreviewFrame(url, x, y, frames.framesPerPageX, frames.framesPerPageY) + } + startPosition = endPosition + } + } + } + return null + } + + private fun updatePreviewX(position: Long) { + val params = previewIv.layoutParams as MarginLayoutParams + val parentWidth = (previewIv.parent as View).width + // calculate the center-offset of the preview image view + val offset = parentWidth * (position.toFloat() / duration.toFloat()) - previewIv.width / 2 + // normalize the offset to keep a minimum distance at left and right + params.marginStart = normalizeOffset( + offset.toInt(), + MIN_PADDING, + parentWidth - MIN_PADDING - previewIv.width + ) + previewIv.layoutParams = params + } + + private fun normalizeOffset(offset: Int, min: Int, max: Int): Int { + return maxOf(min, minOf(max, offset)) + } + + companion object { + const val MIN_PADDING = 20 + } +} diff --git a/app/src/main/res/layout/exo_styled_player_control_view.xml b/app/src/main/res/layout/exo_styled_player_control_view.xml index 5710ef259..bd4d3a4ad 100644 --- a/app/src/main/res/layout/exo_styled_player_control_view.xml +++ b/app/src/main/res/layout/exo_styled_player_control_view.xml @@ -95,6 +95,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file