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) {
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++
}

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.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<Int, Long> = 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))
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<Uri, Boolean> = mutableMapOf()
private val playerSubject = BehaviorSubject.create<Player>()
private val playerReadySignal = PublishSubject.create<Unit>()
val playerUpdates: Observable<PlayerUpdate> = 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
)
}

Wyświetl plik

@ -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<Int, Long> = 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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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