From 4e57432dbb1746e49e99d00f1e1438a52c5d2e5b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 3 Mar 2022 13:12:28 -0400 Subject: [PATCH] Improve smoothness of segmented progress bar and respect video duration. --- .../segmentedprogressbar/Segment.kt | 15 ++-- .../segmentedprogressbar/SegmentState.kt | 6 ++ .../SegmentedProgressBar.kt | 71 +++++++++++++++---- .../SegmentedProgressBarListener.kt | 2 + .../components/segmentedprogressbar/Utils.kt | 2 +- .../mediapreview/VideoControlsDelegate.kt | 29 +++----- .../viewer/page/StoryViewerPageFragment.kt | 35 ++++++--- .../viewer/page/StoryViewerPageState.kt | 3 - .../viewer/page/StoryViewerPageViewModel.kt | 11 ++- .../viewer/page/StoryViewerPlaybackState.kt | 6 +- 10 files changed, 116 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentState.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt index 1a7f607d89..dc3feff88c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt @@ -29,14 +29,14 @@ package org.thoughtcrime.securesms.components.segmentedprogressbar */ class Segment(val animationDurationMillis: Long) { - private var animationProgress: Int = 0 + var animationProgressPercentage: Float = 0f var animationState: AnimationState = AnimationState.IDLE set(value) { - animationProgress = when (value) { - AnimationState.ANIMATED -> 100 - AnimationState.IDLE -> 0 - else -> animationProgress + animationProgressPercentage = when (value) { + AnimationState.ANIMATED -> 1f + AnimationState.IDLE -> 0f + else -> animationProgressPercentage } field = value } @@ -49,9 +49,4 @@ class Segment(val animationDurationMillis: Long) { ANIMATING, IDLE } - - val progressPercentage: Float - get() = animationProgress.toFloat() / 100 - - fun progress() = animationProgress++ } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentState.kt new file mode 100644 index 0000000000..ef5db4111f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentState.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.components.segmentedprogressbar + +data class SegmentState( + val position: Long, + val duration: Long +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt index 490cc6df17..3d728e90f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt @@ -28,13 +28,12 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Path -import android.os.Handler -import android.os.Looper import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.viewpager.widget.ViewPager import org.thoughtcrime.securesms.R +import java.util.concurrent.TimeUnit /** * Created by Tiago Ornelas on 18/04/2020. @@ -42,7 +41,14 @@ import org.thoughtcrime.securesms.R * @see Segment * And the progress of each segment is animated based on a set speed */ -class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, View.OnTouchListener { +class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchListener { + + companion object { + /** + * It is common now for devices to run at 60FPS + */ + val MILLIS_PER_FRAME = TimeUnit.MILLISECONDS.toMillis(17) + } private val path = Path() private val corners = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f) @@ -57,7 +63,10 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie } /** - * Mapping of segment index -> duration in millis + * Mapping of segment index -> duration in millis. Negative durations + * ARE valid but they'll result in a call to SegmentedProgressBarListener#onRequestSegmentProgressPercentage + * which should return the current % position for the currently playing item. This helps + * to avoid synchronizing the seek bar to playback. */ var segmentDurations: Map = mapOf() set(value) { @@ -93,8 +102,6 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie private val selectedSegmentIndex: Int get() = segments.indexOf(this.selectedSegment) - private val animationHandler = Handler(Looper.getMainLooper()) - // Drawing val strokeApplicable: Boolean get() = segmentStrokeWidth * 4 <= measuredHeight @@ -121,6 +128,8 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie */ var listener: SegmentedProgressBarListener? = null + private var lastFrameTimeMillis: Long = 0L + constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { @@ -225,6 +234,8 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie } } } + + onFrame(System.currentTimeMillis()) } /** @@ -233,17 +244,20 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie fun start() { pause() val segment = selectedSegment - if (segment == null) + if (segment == null) { next() - else - animationHandler.postDelayed(this, segment.animationDurationMillis / 100) + } else { + isPaused = false + invalidate() + } } /** * Pauses the animation process */ fun pause() { - animationHandler.removeCallbacks(this) + isPaused = true + lastFrameTimeMillis = 0L } /** @@ -327,15 +341,24 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie if (nextSegment != null) { pause() nextSegment.animationState = Segment.AnimationState.ANIMATING - animationHandler.postDelayed(this, nextSegment.animationDurationMillis / 100) + isPaused = false + invalidate() this.listener?.onPage(oldSegmentIndex, this.selectedSegmentIndex) viewPager?.currentItem = this.selectedSegmentIndex } else { - animationHandler.removeCallbacks(this) + pause() this.listener?.onFinished() } } + private fun getSegmentProgressPercentage(segment: Segment, timeSinceLastFrameMillis: Long): Float { + return if (segment.animationDurationMillis > 0) { + segment.animationProgressPercentage + timeSinceLastFrameMillis.toFloat() / segment.animationDurationMillis + } else { + listener?.onRequestSegmentProgressPercentage() ?: 0f + } + } + private fun initSegments() { this.segments.clear() segments.addAll( @@ -348,12 +371,30 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie reset() } - override fun run() { - if (this.selectedSegment?.progress() ?: 0 >= 100) { + private var isPaused = true + + private fun onFrame(frameTimeMillis: Long) { + if (isPaused) { + return + } + + val lastFrameTimeMillis = this.lastFrameTimeMillis + + this.lastFrameTimeMillis = frameTimeMillis + + val selectedSegment = this.selectedSegment + if (selectedSegment == null) { loadSegment(offset = 1, userAction = false) + } else if (lastFrameTimeMillis > 0L) { + val segmentProgressPercentage = getSegmentProgressPercentage(selectedSegment, frameTimeMillis - lastFrameTimeMillis) + selectedSegment.animationProgressPercentage = segmentProgressPercentage + if (selectedSegment.animationProgressPercentage >= 1f) { + loadSegment(offset = 1, userAction = false) + } else { + this.invalidate() + } } else { this.invalidate() - animationHandler.postDelayed(this, this.selectedSegment?.animationDurationMillis?.let { it / 100 } ?: (timePerSegmentMs / 100)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt index 167fd93ed4..4f7a0e9c05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt @@ -37,4 +37,6 @@ interface SegmentedProgressBarListener { * Notifies when last segment finished animating */ fun onFinished() + + fun onRequestSegmentProgressPercentage(): Float? } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt index c84c201e6d..bd8974b694 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt @@ -78,7 +78,7 @@ fun SegmentedProgressBar.getDrawingComponents( RectF( startBound + stroke, height - stroke, - startBound + segment.progressPercentage * segmentWidth, + startBound + segment.animationProgressPercentage * segmentWidth, stroke ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt index a5a728e45e..1218911db3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt @@ -1,10 +1,7 @@ package org.thoughtcrime.securesms.mediapreview import android.net.Uri -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.subjects.BehaviorSubject -import io.reactivex.rxjava3.subjects.PublishSubject import org.thoughtcrime.securesms.video.VideoPlayer /** @@ -14,12 +11,15 @@ class VideoControlsDelegate { private val playWhenReady: MutableMap = mutableMapOf() private val playerSubject = BehaviorSubject.create() - private val playerReadySignal = PublishSubject.create() - val playerUpdates: Observable = playerReadySignal - .observeOn(AndroidSchedulers.mainThread()) - .flatMap { playerSubject } - .filter { it.videoPlayer != null } - .map { PlayerUpdate(it.uri, it.videoPlayer?.duration!!) } + + fun getPlayerState(uri: Uri): PlayerState? { + val player = playerSubject.value + return if (player?.uri == uri && player.videoPlayer != null) { + PlayerState(uri, player.videoPlayer.playbackPosition, player.videoPlayer.duration) + } else { + null + } + } fun pause() = playerSubject.value?.videoPlayer?.pause() @@ -41,14 +41,6 @@ class VideoControlsDelegate { fun attachPlayer(uri: Uri, videoPlayer: VideoPlayer?) { playerSubject.onNext(Player(uri, videoPlayer)) - if ((videoPlayer?.duration ?: -1L) > 0L) { - playerReadySignal.onNext(Unit) - } else { - videoPlayer?.setPlayerStateCallbacks { - playerReadySignal.onNext(Unit) - } - } - if (playWhenReady[uri] == true) { playWhenReady[uri] = false videoPlayer?.play() @@ -64,8 +56,9 @@ class VideoControlsDelegate { val videoPlayer: VideoPlayer? = null ) - data class PlayerUpdate( + data class PlayerState( val mediaUri: Uri, + val position: Long, val duration: Long ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index cddcd68ee3..4f5ff06b03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.util.AvatarUtil import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout import org.thoughtcrime.securesms.util.visible @@ -173,6 +174,25 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), override fun onFinished() { viewModel.goToNextPost() } + + override fun onRequestSegmentProgressPercentage(): Float? { + val attachmentUri = if (viewModel.hasPost() && MediaUtil.isVideo(viewModel.getPost().attachment)) { + viewModel.getPost().attachment.uri + } else { + null + } + + return if (attachmentUri != null) { + val playerState = videoControlsDelegate.getPlayerState(attachmentUri) + if (playerState != null) { + playerState.position.toFloat() / playerState.duration + } else { + null + } + } else { + null + } + } } sharedViewModel.isScrolling.observe(viewLifecycleOwner) { isScrolling -> @@ -208,7 +228,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), val durations: Map = state.posts .mapIndexed { index, storyPost -> - index to (storyPost.attachment.uri?.let { state.durations[it] } ?: TimeUnit.SECONDS.toMillis(5)) + index to if (MediaUtil.isVideo(storyPost.attachment)) -1L else TimeUnit.SECONDS.toMillis(5) } .toMap() @@ -246,18 +266,17 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), } } - lifecycleDisposable += videoControlsDelegate.playerUpdates.subscribe { update -> - if (update.duration > 0L) { - viewModel.setDuration(update.mediaUri, update.duration) - } - } - adjustConstraintsForScreenDimensions(viewsAndReplies, cardWrapper, card) } + override fun onResume() { + super.onResume() + viewModel.setIsFragmentResumed(true) + } + override fun onPause() { super.onPause() - pauseProgress() + viewModel.setIsFragmentResumed(false) } override fun onFinishForwardAction() = Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageState.kt index 01e2045143..b0921bec94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageState.kt @@ -1,10 +1,7 @@ package org.thoughtcrime.securesms.stories.viewer.page -import android.net.Uri - data class StoryViewerPageState( val posts: List = emptyList(), - val durations: Map = emptyMap(), val selectedPostIndex: Int = 0, val replyState: ReplyState = ReplyState.NONE ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index 9a0f57bc36..9a2f9bf53c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.stories.viewer.page -import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -42,12 +41,6 @@ class StoryViewerPageViewModel( refresh() } - fun setDuration(uri: Uri, duration: Long) { - store.update { - it.copy(durations = it.durations + (uri to duration)) - } - } - fun refresh() { disposables.clear() disposables += repository.getStoryPostsFor(recipientId).subscribe { posts -> @@ -117,6 +110,10 @@ class StoryViewerPageViewModel( storyViewerDialogSubject.onNext(Optional.of(StoryViewerDialog.GroupDirectReply(recipientId, storyId))) } + fun setIsFragmentResumed(isFragmentResumed: Boolean) { + storyViewerPlaybackStore.update { it.copy(isFragmentResumed = isFragmentResumed) } + } + fun setIsUserScrollingParent(isUserScrollingParent: Boolean) { storyViewerPlaybackStore.update { it.copy(isUserScrollingParent = isUserScrollingParent) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt index 6a2edee7e6..aa5bac700e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt @@ -11,7 +11,8 @@ data class StoryViewerPlaybackState( val isDisplayingCaptionOverlay: Boolean = false, val isUserScrollingParent: Boolean = false, val isSelectedPage: Boolean = false, - val isDisplayingSlate: Boolean = false + val isDisplayingSlate: Boolean = false, + val isFragmentResumed: Boolean = false ) { val isPaused: Boolean = !areSegmentsInitialized || isUserTouching || @@ -24,5 +25,6 @@ data class StoryViewerPlaybackState( isDisplayingCaptionOverlay || isUserScrollingParent || !isSelectedPage || - isDisplayingSlate + isDisplayingSlate || + !isFragmentResumed }