Improve smoothness of segmented progress bar and respect video duration.

fork-5.53.8
Alex Hart 2022-03-03 13:12:28 -04:00
rodzic 63412b0153
commit 4e57432dbb
10 zmienionych plików z 116 dodań i 64 usunięć

Wyświetl plik

@ -29,14 +29,14 @@ package org.thoughtcrime.securesms.components.segmentedprogressbar
*/ */
class Segment(val animationDurationMillis: Long) { class Segment(val animationDurationMillis: Long) {
private var animationProgress: Int = 0 var animationProgressPercentage: Float = 0f
var animationState: AnimationState = AnimationState.IDLE var animationState: AnimationState = AnimationState.IDLE
set(value) { set(value) {
animationProgress = when (value) { animationProgressPercentage = when (value) {
AnimationState.ANIMATED -> 100 AnimationState.ANIMATED -> 1f
AnimationState.IDLE -> 0 AnimationState.IDLE -> 0f
else -> animationProgress else -> animationProgressPercentage
} }
field = value field = value
} }
@ -49,9 +49,4 @@ class Segment(val animationDurationMillis: Long) {
ANIMATING, ANIMATING,
IDLE IDLE
} }
val progressPercentage: Float
get() = animationProgress.toFloat() / 100
fun progress() = animationProgress++
} }

Wyświetl plik

@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.components.segmentedprogressbar
data class SegmentState(
val position: Long,
val duration: Long
)

Wyświetl plik

@ -28,13 +28,12 @@ import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Path import android.graphics.Path
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import androidx.viewpager.widget.ViewPager import androidx.viewpager.widget.ViewPager
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
/** /**
* Created by Tiago Ornelas on 18/04/2020. * Created by Tiago Ornelas on 18/04/2020.
@ -42,7 +41,14 @@ import org.thoughtcrime.securesms.R
* @see Segment * @see Segment
* And the progress of each segment is animated based on a set speed * 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 path = Path()
private val corners = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f) 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<Int, Long> = mapOf() var segmentDurations: Map<Int, Long> = mapOf()
set(value) { set(value) {
@ -93,8 +102,6 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie
private val selectedSegmentIndex: Int private val selectedSegmentIndex: Int
get() = segments.indexOf(this.selectedSegment) get() = segments.indexOf(this.selectedSegment)
private val animationHandler = Handler(Looper.getMainLooper())
// Drawing // Drawing
val strokeApplicable: Boolean val strokeApplicable: Boolean
get() = segmentStrokeWidth * 4 <= measuredHeight get() = segmentStrokeWidth * 4 <= measuredHeight
@ -121,6 +128,8 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie
*/ */
var listener: SegmentedProgressBarListener? = null var listener: SegmentedProgressBarListener? = null
private var lastFrameTimeMillis: Long = 0L
constructor(context: Context) : super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { 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() { fun start() {
pause() pause()
val segment = selectedSegment val segment = selectedSegment
if (segment == null) if (segment == null) {
next() next()
else } else {
animationHandler.postDelayed(this, segment.animationDurationMillis / 100) isPaused = false
invalidate()
}
} }
/** /**
* Pauses the animation process * Pauses the animation process
*/ */
fun pause() { fun pause() {
animationHandler.removeCallbacks(this) isPaused = true
lastFrameTimeMillis = 0L
} }
/** /**
@ -327,15 +341,24 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie
if (nextSegment != null) { if (nextSegment != null) {
pause() pause()
nextSegment.animationState = Segment.AnimationState.ANIMATING nextSegment.animationState = Segment.AnimationState.ANIMATING
animationHandler.postDelayed(this, nextSegment.animationDurationMillis / 100) isPaused = false
invalidate()
this.listener?.onPage(oldSegmentIndex, this.selectedSegmentIndex) this.listener?.onPage(oldSegmentIndex, this.selectedSegmentIndex)
viewPager?.currentItem = this.selectedSegmentIndex viewPager?.currentItem = this.selectedSegmentIndex
} else { } else {
animationHandler.removeCallbacks(this) pause()
this.listener?.onFinished() 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() { private fun initSegments() {
this.segments.clear() this.segments.clear()
segments.addAll( segments.addAll(
@ -348,12 +371,30 @@ class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, Vie
reset() reset()
} }
override fun run() { private var isPaused = true
if (this.selectedSegment?.progress() ?: 0 >= 100) {
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) loadSegment(offset = 1, userAction = false)
} else { } else {
this.invalidate() this.invalidate()
animationHandler.postDelayed(this, this.selectedSegment?.animationDurationMillis?.let { it / 100 } ?: (timePerSegmentMs / 100)) }
} else {
this.invalidate()
} }
} }

Wyświetl plik

@ -37,4 +37,6 @@ interface SegmentedProgressBarListener {
* Notifies when last segment finished animating * Notifies when last segment finished animating
*/ */
fun onFinished() fun onFinished()
fun onRequestSegmentProgressPercentage(): Float?
} }

Wyświetl plik

@ -78,7 +78,7 @@ fun SegmentedProgressBar.getDrawingComponents(
RectF( RectF(
startBound + stroke, startBound + stroke,
height - stroke, height - stroke,
startBound + segment.progressPercentage * segmentWidth, startBound + segment.animationProgressPercentage * segmentWidth,
stroke stroke
) )
) )

Wyświetl plik

@ -1,10 +1,7 @@
package org.thoughtcrime.securesms.mediapreview package org.thoughtcrime.securesms.mediapreview
import android.net.Uri 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.BehaviorSubject
import io.reactivex.rxjava3.subjects.PublishSubject
import org.thoughtcrime.securesms.video.VideoPlayer import org.thoughtcrime.securesms.video.VideoPlayer
/** /**
@ -14,12 +11,15 @@ class VideoControlsDelegate {
private val playWhenReady: MutableMap<Uri, Boolean> = mutableMapOf() private val playWhenReady: MutableMap<Uri, Boolean> = mutableMapOf()
private val playerSubject = BehaviorSubject.create<Player>() private val playerSubject = BehaviorSubject.create<Player>()
private val playerReadySignal = PublishSubject.create<Unit>()
val playerUpdates: Observable<PlayerUpdate> = playerReadySignal fun getPlayerState(uri: Uri): PlayerState? {
.observeOn(AndroidSchedulers.mainThread()) val player = playerSubject.value
.flatMap { playerSubject } return if (player?.uri == uri && player.videoPlayer != null) {
.filter { it.videoPlayer != null } PlayerState(uri, player.videoPlayer.playbackPosition, player.videoPlayer.duration)
.map { PlayerUpdate(it.uri, it.videoPlayer?.duration!!) } } else {
null
}
}
fun pause() = playerSubject.value?.videoPlayer?.pause() fun pause() = playerSubject.value?.videoPlayer?.pause()
@ -41,14 +41,6 @@ class VideoControlsDelegate {
fun attachPlayer(uri: Uri, videoPlayer: VideoPlayer?) { fun attachPlayer(uri: Uri, videoPlayer: VideoPlayer?) {
playerSubject.onNext(Player(uri, videoPlayer)) playerSubject.onNext(Player(uri, videoPlayer))
if ((videoPlayer?.duration ?: -1L) > 0L) {
playerReadySignal.onNext(Unit)
} else {
videoPlayer?.setPlayerStateCallbacks {
playerReadySignal.onNext(Unit)
}
}
if (playWhenReady[uri] == true) { if (playWhenReady[uri] == true) {
playWhenReady[uri] = false playWhenReady[uri] = false
videoPlayer?.play() videoPlayer?.play()
@ -64,8 +56,9 @@ class VideoControlsDelegate {
val videoPlayer: VideoPlayer? = null val videoPlayer: VideoPlayer? = null
) )
data class PlayerUpdate( data class PlayerState(
val mediaUri: Uri, val mediaUri: Uri,
val position: Long,
val duration: Long val duration: Long
) )
} }

Wyświetl plik

@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.util.AvatarUtil
import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout
import org.thoughtcrime.securesms.util.visible import org.thoughtcrime.securesms.util.visible
@ -173,6 +174,25 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
override fun onFinished() { override fun onFinished() {
viewModel.goToNextPost() 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 -> sharedViewModel.isScrolling.observe(viewLifecycleOwner) { isScrolling ->
@ -208,7 +228,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
val durations: Map<Int, Long> = state.posts val durations: Map<Int, Long> = state.posts
.mapIndexed { index, storyPost -> .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() .toMap()
@ -246,18 +266,17 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
} }
} }
lifecycleDisposable += videoControlsDelegate.playerUpdates.subscribe { update -> adjustConstraintsForScreenDimensions(viewsAndReplies, cardWrapper, card)
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() { override fun onPause() {
super.onPause() super.onPause()
pauseProgress() viewModel.setIsFragmentResumed(false)
} }
override fun onFinishForwardAction() = Unit override fun onFinishForwardAction() = Unit

Wyświetl plik

@ -1,10 +1,7 @@
package org.thoughtcrime.securesms.stories.viewer.page package org.thoughtcrime.securesms.stories.viewer.page
import android.net.Uri
data class StoryViewerPageState( data class StoryViewerPageState(
val posts: List<StoryPost> = emptyList(), val posts: List<StoryPost> = emptyList(),
val durations: Map<Uri, Long> = emptyMap(),
val selectedPostIndex: Int = 0, val selectedPostIndex: Int = 0,
val replyState: ReplyState = ReplyState.NONE val replyState: ReplyState = ReplyState.NONE
) { ) {

Wyświetl plik

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.stories.viewer.page package org.thoughtcrime.securesms.stories.viewer.page
import android.net.Uri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -42,12 +41,6 @@ class StoryViewerPageViewModel(
refresh() refresh()
} }
fun setDuration(uri: Uri, duration: Long) {
store.update {
it.copy(durations = it.durations + (uri to duration))
}
}
fun refresh() { fun refresh() {
disposables.clear() disposables.clear()
disposables += repository.getStoryPostsFor(recipientId).subscribe { posts -> disposables += repository.getStoryPostsFor(recipientId).subscribe { posts ->
@ -117,6 +110,10 @@ class StoryViewerPageViewModel(
storyViewerDialogSubject.onNext(Optional.of(StoryViewerDialog.GroupDirectReply(recipientId, storyId))) storyViewerDialogSubject.onNext(Optional.of(StoryViewerDialog.GroupDirectReply(recipientId, storyId)))
} }
fun setIsFragmentResumed(isFragmentResumed: Boolean) {
storyViewerPlaybackStore.update { it.copy(isFragmentResumed = isFragmentResumed) }
}
fun setIsUserScrollingParent(isUserScrollingParent: Boolean) { fun setIsUserScrollingParent(isUserScrollingParent: Boolean) {
storyViewerPlaybackStore.update { it.copy(isUserScrollingParent = isUserScrollingParent) } storyViewerPlaybackStore.update { it.copy(isUserScrollingParent = isUserScrollingParent) }
} }

Wyświetl plik

@ -11,7 +11,8 @@ data class StoryViewerPlaybackState(
val isDisplayingCaptionOverlay: Boolean = false, val isDisplayingCaptionOverlay: Boolean = false,
val isUserScrollingParent: Boolean = false, val isUserScrollingParent: Boolean = false,
val isSelectedPage: Boolean = false, val isSelectedPage: Boolean = false,
val isDisplayingSlate: Boolean = false val isDisplayingSlate: Boolean = false,
val isFragmentResumed: Boolean = false
) { ) {
val isPaused: Boolean = !areSegmentsInitialized || val isPaused: Boolean = !areSegmentsInitialized ||
isUserTouching || isUserTouching ||
@ -24,5 +25,6 @@ data class StoryViewerPlaybackState(
isDisplayingCaptionOverlay || isDisplayingCaptionOverlay ||
isUserScrollingParent || isUserScrollingParent ||
!isSelectedPage || !isSelectedPage ||
isDisplayingSlate isDisplayingSlate ||
!isFragmentResumed
} }