diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 78569be0e7..d6a0d74f9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -579,7 +579,7 @@ public class MmsDatabase extends MessageDatabase { @Override public @NonNull MessageDatabase.Reader getAllStories() { - return new Reader(rawQuery(IS_STORY_CLAUSE, null, true, -1L)); + return new Reader(rawQuery(IS_STORY_CLAUSE, null, false, -1L)); } @Override @@ -660,7 +660,7 @@ public class MmsDatabase extends MessageDatabase { "FROM " + TABLE_NAME + " JOIN " + ThreadDatabase.TABLE_NAME + " " + "ON " + TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + "WHERE " + IS_STORY_CLAUSE + " " + - "ORDER BY " + TABLE_NAME + "." + DATE_SENT + " DESC"; + "ORDER BY " + TABLE_NAME + "." + DATE_SENT + " DESC, " + TABLE_NAME + "." + VIEWED_RECEIPT_COUNT + " ASC"; List recipientIds; try (Cursor cursor = db.rawQuery(query, null)) { 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 1218911db3..e8e69f4bca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.mediapreview import android.net.Uri -import io.reactivex.rxjava3.subjects.BehaviorSubject import org.thoughtcrime.securesms.video.VideoPlayer /** @@ -10,36 +9,42 @@ import org.thoughtcrime.securesms.video.VideoPlayer class VideoControlsDelegate { private val playWhenReady: MutableMap = mutableMapOf() - private val playerSubject = BehaviorSubject.create() + private var player: Player? = null fun getPlayerState(uri: Uri): PlayerState? { - val player = playerSubject.value + val player: Player? = this.player return if (player?.uri == uri && player.videoPlayer != null) { - PlayerState(uri, player.videoPlayer.playbackPosition, player.videoPlayer.duration) + PlayerState(uri, player.videoPlayer.playbackPosition, player.videoPlayer.duration, player.isGif, player.loopCount) } else { null } } - fun pause() = playerSubject.value?.videoPlayer?.pause() + fun pause() = player?.videoPlayer?.pause() fun resume(uri: Uri) { - val player = playerSubject.value if (player?.uri == uri) { - player.videoPlayer?.play() + player?.videoPlayer?.play() } else { playWhenReady[uri] = true } - playerSubject.value?.videoPlayer?.play() + this.player?.videoPlayer?.play() } fun restart() { - playerSubject.value?.videoPlayer?.playbackPosition = 0L + player?.videoPlayer?.playbackPosition = 0L } - fun attachPlayer(uri: Uri, videoPlayer: VideoPlayer?) { - playerSubject.onNext(Player(uri, videoPlayer)) + fun onPlayerPositionDiscontinuity(reason: Int) { + val player = this.player + if (player != null && player.isGif) { + this.player = player.copy(loopCount = if (reason == 0) player.loopCount + 1 else 0) + } + } + + fun attachPlayer(uri: Uri, videoPlayer: VideoPlayer?, isGif: Boolean) { + player = Player(uri, videoPlayer, isGif) if (playWhenReady[uri] == true) { playWhenReady[uri] = false @@ -48,17 +53,21 @@ class VideoControlsDelegate { } fun detachPlayer() { - playerSubject.onNext(Player()) + player = Player() } private data class Player( val uri: Uri = Uri.EMPTY, - val videoPlayer: VideoPlayer? = null + val videoPlayer: VideoPlayer? = null, + val isGif: Boolean = false, + val loopCount: Int = 0 ) data class PlayerState( val mediaUri: Uri, val position: Long, - val duration: Long + val duration: Long, + val isGif: Boolean, + val loopCount: Int ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java index 910bbd15ff..29dfd5cc29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java @@ -48,6 +48,11 @@ public final class VideoMediaPreviewFragment extends MediaPreviewFragment { videoView.setWindow(requireActivity().getWindow()); videoView.setVideoSource(new VideoSlide(getContext(), uri, size, false), autoPlay); + videoView.setPlayerPositionDiscontinuityCallback((v, r) -> { + if (events.getVideoControlsDelegate() != null) { + events.getVideoControlsDelegate().onPlayerPositionDiscontinuity(r); + } + }); if (isVideoGif) { videoView.hideControls(); @@ -74,7 +79,7 @@ public final class VideoMediaPreviewFragment extends MediaPreviewFragment { } if (events.getVideoControlsDelegate() != null) { - events.getVideoControlsDelegate().attachPlayer(getUri(), videoView); + events.getVideoControlsDelegate().attachPlayer(getUri(), videoView, isVideoGif); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt index 9f93c2e4dd..4774f5c814 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt @@ -23,6 +23,10 @@ data class StoriesLandingItemData( -1 } else if (!storyRecipient.isMyStory && other.storyRecipient.isMyStory) { 1 + } else if (storyViewState == StoryViewState.UNVIEWED && other.storyViewState != StoryViewState.UNVIEWED) { + -1 + } else if (storyViewState != StoryViewState.UNVIEWED && other.storyViewState == StoryViewState.UNVIEWED) { + 1 } else { -dateInMilliseconds.compareTo(other.dateInMilliseconds) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt index 5a904ddd95..b12a0a84a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt @@ -72,13 +72,14 @@ class StoriesLandingRepository(context: Context) { private fun createStoriesLandingItemData(sender: Recipient, messageRecords: List): Observable { val itemDataObservable = Observable.create { emitter -> fun refresh(sender: Recipient) { + val primaryIndex = messageRecords.indexOfFirst { !it.isOutgoing && it.viewedReceiptCount == 0 }.takeIf { it > -1 } ?: 0 val itemData = StoriesLandingItemData( storyRecipient = sender, storyViewState = StoryViewState.NONE, hasReplies = messageRecords.any { SignalDatabase.mms.getNumberOfStoryReplies(it.id) > 0 }, hasRepliesFromSelf = messageRecords.any { SignalDatabase.mms.hasSelfReplyInStory(it.id) }, isHidden = sender.shouldHideStory(), - primaryStory = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecords.first()), + primaryStory = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecords[primaryIndex]), secondaryStory = if (sender.isMyStory) messageRecords.drop(1).firstOrNull()?.let { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) } else null diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt index 2fa83c8b22..9604cfb143 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt @@ -28,7 +28,7 @@ class StoryPost( override fun isVideo(): Boolean = MediaUtil.isVideo(attachment) } - class TextContent(uri: Uri, val recordId: Long, hasBody: Boolean) : Content(uri) { + class TextContent(uri: Uri, val recordId: Long, hasBody: Boolean, val length: Int) : Content(uri) { override val transferState: Int = if (hasBody) AttachmentDatabase.TRANSFER_PROGRESS_DONE else AttachmentDatabase.TRANSFER_PROGRESS_FAILED override fun isVideo(): Boolean = false 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 10e1aef644..e71b1c67d9 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 @@ -65,6 +65,7 @@ import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlin.math.max +import kotlin.math.min class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), @@ -216,7 +217,7 @@ class StoryViewerPageFragment : return if (attachmentUri != null) { val playerState = videoControlsDelegate.getPlayerState(attachmentUri) if (playerState != null) { - playerState.position.toFloat() / playerState.duration + getVideoPlaybackPosition(playerState) / getVideoPlaybackDuration(playerState) } else { null } @@ -266,7 +267,11 @@ class StoryViewerPageFragment : val durations: Map = state.posts .mapIndexed { index, storyPost -> - index to if (storyPost.content.isVideo()) -1L else TimeUnit.SECONDS.toMillis(5) + index to when { + storyPost.content.isVideo() -> -1L + storyPost.content is StoryPost.Content.TextContent -> calculateDurationForText(storyPost.content) + else -> DEFAULT_DURATION + } } .toMap() @@ -331,6 +336,28 @@ class StoryViewerPageFragment : viewModel.setIsDisplayingForwardDialog(false) } + private fun calculateDurationForText(textContent: StoryPost.Content.TextContent): Long { + val divisionsOf15 = textContent.length / CHARACTERS_PER_SECOND + return TimeUnit.SECONDS.toMillis(divisionsOf15) + MIN_TEXT_STORY_PLAYBACK + } + + private fun getVideoPlaybackPosition(playerState: VideoControlsDelegate.PlayerState): Float { + return if (playerState.isGif) { + playerState.position.toFloat() + (playerState.duration * playerState.loopCount) + } else { + playerState.position.toFloat() + } + } + + private fun getVideoPlaybackDuration(playerState: VideoControlsDelegate.PlayerState): Long { + return if (playerState.isGif) { + val timeToPlayMinLoops = playerState.duration * MIN_GIF_LOOPS + max(MIN_GIF_PLAYBACK_DURATION, timeToPlayMinLoops) + } else { + min(playerState.duration, MAX_VIDEO_PLAYBACK_DURATION) + } + } + private fun hideChrome() { animateChrome(0f) } @@ -678,6 +705,13 @@ class StoryViewerPageFragment : } companion object { + private val MAX_VIDEO_PLAYBACK_DURATION: Long = TimeUnit.SECONDS.toMillis(30) + private val MIN_GIF_LOOPS: Long = 3L + private val MIN_GIF_PLAYBACK_DURATION = TimeUnit.SECONDS.toMillis(5) + private val MIN_TEXT_STORY_PLAYBACK = TimeUnit.SECONDS.toMillis(3) + private val CHARACTERS_PER_SECOND = 15L + private val DEFAULT_DURATION = TimeUnit.SECONDS.toMillis(5) + private const val ARG_STORY_RECIPIENT_ID = "arg.story.recipient.id" private const val ARG_STORY_ID = "arg.story.id" diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt index bb760b38ea..50faaa2f9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt @@ -5,6 +5,7 @@ import android.net.Uri import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.BreakIteratorCompat import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.conversation.ConversationMessage @@ -178,7 +179,8 @@ class StoryViewerPageRepository(context: Context) { StoryPost.Content.TextContent( uri = Uri.parse("story_text_post://${record.id}"), recordId = record.id, - hasBody = canParseToTextStory(record.body) + hasBody = canParseToTextStory(record.body), + length = getTextStoryLength(record.body) ) } else { StoryPost.Content.AttachmentContent( @@ -187,6 +189,16 @@ class StoryViewerPageRepository(context: Context) { } } + private fun getTextStoryLength(body: String): Int { + return if (canParseToTextStory(body)) { + val breakIteratorCompat = BreakIteratorCompat.getInstance() + breakIteratorCompat.setText(StoryTextPost.parseFrom(Base64.decode(body)).body) + breakIteratorCompat.countBreaks() + } else { + 0 + } + } + private fun canParseToTextStory(body: String): Boolean { return if (body.isNotEmpty()) { try { 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 b59ce1f64a..6351aaee84 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 @@ -49,6 +49,9 @@ class StoryViewerPageViewModel( val startIndex = if (state.posts.isEmpty() && initialStoryId > 0) { val initialIndex = posts.indexOfFirst { it.id == initialStoryId } initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex + } else if (state.posts.isEmpty()) { + val initialIndex = posts.indexOfFirst { !it.conversationMessage.messageRecord.isOutgoing && it.conversationMessage.messageRecord.viewedReceiptCount == 0 } + initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex } else { state.selectedPostIndex } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index dc8d5cd456..f669757ca2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -83,8 +83,8 @@ public class VideoPlayer extends FrameLayout { this.exoControls = new PlayerControlView(getContext()); this.exoControls.setShowTimeoutMs(-1); - this.exoPlayerListener = new ExoPlayerListener(this, window, playerStateCallback, playerPositionDiscontinuityCallback); - this.playerListener = new Player.Listener() { + this.exoPlayerListener = new ExoPlayerListener(); + this.playerListener = new Player.Listener() { @Override public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { onPlaybackStateChanged(playWhenReady, exoPlayer.getPlaybackState()); @@ -244,9 +244,6 @@ public class VideoPlayer extends FrameLayout { public void setWindow(@Nullable Window window) { this.window = window; - if (exoPlayerListener != null) { - exoPlayerListener.setWindow(window); - } } public void setPlayerStateCallbacks(@Nullable PlayerStateCallback playerStateCallback) { @@ -273,35 +270,16 @@ public class VideoPlayer extends FrameLayout { } } - private static class ExoPlayerListener implements Player.Listener { - private final VideoPlayer videoPlayer; - private Window window; - private final PlayerStateCallback playerStateCallback; - private final PlayerPositionDiscontinuityCallback playerPositionDiscontinuityCallback; - - ExoPlayerListener(@NonNull VideoPlayer videoPlayer, - @Nullable Window window, - @Nullable PlayerStateCallback playerStateCallback, - @Nullable PlayerPositionDiscontinuityCallback playerPositionDiscontinuityCallback) - { - this.videoPlayer = videoPlayer; - this.window = window; - this.playerStateCallback = playerStateCallback; - this.playerPositionDiscontinuityCallback = playerPositionDiscontinuityCallback; - } + private class ExoPlayerListener implements Player.Listener { @Override public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { - onPlaybackStateChanged(playWhenReady, videoPlayer.exoPlayer.getPlaybackState()); + onPlaybackStateChanged(playWhenReady, exoPlayer.getPlaybackState()); } @Override public void onPlaybackStateChanged(int playbackState) { - onPlaybackStateChanged(videoPlayer.exoPlayer.getPlayWhenReady(), playbackState); - } - - public void setWindow(Window window) { - this.window = window; + onPlaybackStateChanged(exoPlayer.getPlayWhenReady(), playbackState); } private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) { @@ -334,7 +312,7 @@ public class VideoPlayer extends FrameLayout { int reason) { if (playerPositionDiscontinuityCallback != null) { - playerPositionDiscontinuityCallback.onPositionDiscontinuity(videoPlayer, reason); + playerPositionDiscontinuityCallback.onPositionDiscontinuity(VideoPlayer.this, reason); } }