From 2d7c0433982f72b083775290719cb779a9d7363c Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 30 Jun 2021 16:07:00 -0300 Subject: [PATCH] Implement a playback speed toggle for voice notes. --- .../securesms/BindableConversationItem.java | 1 + .../securesms/components/AudioView.java | 14 +- .../components/ConversationItemFooter.java | 231 +++++++++++++----- .../components/PlaybackSpeedToggleTextView.kt | 105 ++++++++ .../voice/VoiceNoteMediaController.java | 13 +- .../voice/VoiceNotePlaybackController.kt | 23 ++ .../voice/VoiceNotePlaybackParameters.java | 41 ++++ .../voice/VoiceNotePlaybackPreparer.java | 16 +- .../voice/VoiceNotePlaybackService.java | 47 +++- .../voice/VoiceNotePlaybackState.java | 53 ---- .../voice/VoiceNotePlaybackState.kt | 39 +++ .../conversation/ConversationFragment.java | 5 + .../conversation/ConversationItem.java | 70 ++++-- .../mediaoverview/MediaGalleryAllAdapter.java | 4 + .../conversation_item_footer_incoming.xml | 141 +++++++++++ ... => conversation_item_footer_outgoing.xml} | 60 ++++- .../conversation_item_received_multimedia.xml | 4 +- .../conversation_item_received_text_only.xml | 4 +- .../conversation_item_sent_multimedia.xml | 4 +- .../conversation_item_sent_text_only.xml | 4 +- .../layout/conversation_item_thumbnail.xml | 2 +- .../layout/longmessage_bubble_received.xml | 2 +- .../res/layout/longmessage_bubble_sent.xml | 2 +- .../main/res/layout/shared_contact_view.xml | 16 +- app/src/main/res/values-night/dark_colors.xml | 3 + app/src/main/res/values/arrays.xml | 13 +- app/src/main/res/values/attrs.xml | 4 + app/src/main/res/values/light_colors.xml | 3 + app/src/main/res/values/strings.xml | 5 + 29 files changed, 754 insertions(+), 175 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/PlaybackSpeedToggleTextView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackParameters.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.kt create mode 100644 app/src/main/res/layout/conversation_item_footer_incoming.xml rename app/src/main/res/layout/{conversation_item_footer.xml => conversation_item_footer_outgoing.xml} (56%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 9104e8cb4..0da79b86d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -75,6 +75,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onVoiceNotePause(@NonNull Uri uri); void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position); void onVoiceNoteSeekTo(@NonNull Uri uri, double position); + void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed); void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange); void onChatSessionRefreshLearnMoreClicked(); void onBadDecryptLearnMoreClicked(@NonNull RecipientId author); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java index 4373fa308..7f99d7e84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -110,7 +110,7 @@ public final class AudioView extends FrameLayout { this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE); this.waveFormThumbTint = typedArray.getColor(R.styleable.AudioView_waveformThumbTint, Color.WHITE); - progressAndPlay.getBackground().setColorFilter(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK), PorterDuff.Mode.SRC_IN); + setProgressAndPlayBackgroundTint(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK)); } finally { if (typedArray != null) { typedArray.recycle(); @@ -130,6 +130,10 @@ public final class AudioView extends FrameLayout { EventBus.getDefault().unregister(this); } + public void setProgressAndPlayBackgroundTint(@ColorInt int color) { + progressAndPlay.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN); + } + public Observer getPlaybackStateObserver() { return playbackStateObserver; } @@ -215,6 +219,7 @@ public final class AudioView extends FrameLayout { onProgress(voiceNotePlaybackState.getUri(), (double) voiceNotePlaybackState.getPlayheadPositionMillis() / voiceNotePlaybackState.getTrackDuration(), voiceNotePlaybackState.getPlayheadPositionMillis()); + onSpeedChanged(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getSpeed()); } private void onDuration(@NonNull Uri uri, long durationMillis) { @@ -274,6 +279,10 @@ public final class AudioView extends FrameLayout { } } + private void onSpeedChanged(@NonNull Uri uri, float speed) { + callbacks.onSpeedChanged(speed, isTarget(uri)); + } + private boolean isTarget(@NonNull Uri uri) { return hasAudioUri() && Objects.equals(uri, audioSlide.getUri()); } @@ -451,6 +460,8 @@ public final class AudioView extends FrameLayout { if (callbacks != null) { if (wasPlaying) { callbacks.onSeekTo(audioSlide.getUri(), getProgress()); + } else { + callbacks.onProgressUpdated(durationMillis, Math.round(durationMillis * getProgress())); } } } @@ -475,6 +486,7 @@ public final class AudioView extends FrameLayout { void onPause(@NonNull Uri audioUri); void onSeekTo(@NonNull Uri audioUri, double progress); void onStopAndReset(@NonNull Uri audioUri); + void onSpeedChanged(float speed, boolean isPlaying); void onProgressUpdated(long durationMillis, long playheadMillis); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index 18aab4498..bbf090539 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -1,28 +1,33 @@ package org.thoughtcrime.securesms.components; import android.Manifest; +import android.animation.Animator; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; +import android.graphics.Rect; import android.util.AttributeSet; import android.view.View; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.DrawableRes; +import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; import com.airbnb.lottie.LottieAnimationView; import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.model.KeyPath; import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.ApplicationContext; +import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; @@ -30,7 +35,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.DateUtils; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat; @@ -40,17 +44,24 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.util.Locale; import java.util.concurrent.TimeUnit; -public class ConversationItemFooter extends LinearLayout { +public class ConversationItemFooter extends ConstraintLayout { - private TextView dateView; - private TextView simView; - private ExpirationTimerView timerView; - private ImageView insecureIndicatorView; - private DeliveryStatusView deliveryStatusView; - private boolean onlyShowSendingStatus; - private View audioSpace; - private TextView audioDuration; - private LottieAnimationView revealDot; + private TextView dateView; + private TextView simView; + private ExpirationTimerView timerView; + private ImageView insecureIndicatorView; + private DeliveryStatusView deliveryStatusView; + private boolean onlyShowSendingStatus; + private TextView audioDuration; + private LottieAnimationView revealDot; + private PlaybackSpeedToggleTextView playbackSpeedToggleTextView; + private boolean isOutgoing; + private boolean hasShrunkDate; + + private OnTouchDelegateChangedListener onTouchDelegateChangedListener; + + private final Rect speedToggleHitRect = new Rect(); + private final int touchTargetSize = ViewUtil.dpToPx(48); public ConversationItemFooter(Context context) { super(context); @@ -68,24 +79,55 @@ public class ConversationItemFooter extends LinearLayout { } private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.conversation_item_footer, this); - - dateView = findViewById(R.id.footer_date); - simView = findViewById(R.id.footer_sim_info); - timerView = findViewById(R.id.footer_expiration_timer); - insecureIndicatorView = findViewById(R.id.footer_insecure_indicator); - deliveryStatusView = findViewById(R.id.footer_delivery_status); - audioDuration = findViewById(R.id.footer_audio_duration); - audioSpace = findViewById(R.id.footer_audio_duration_space); - revealDot = findViewById(R.id.footer_revealed_dot); - + final TypedArray typedArray; if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0); + typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0); + } else { + typedArray = null; + } + + final @LayoutRes int contentId; + if (typedArray != null) { + int mode = typedArray.getInt(R.styleable.ConversationItemFooter_footer_mode, 0); + isOutgoing = mode == 0; + + if (isOutgoing) { + contentId = R.layout.conversation_item_footer_outgoing; + } else { + contentId = R.layout.conversation_item_footer_incoming; + } + } else { + contentId = R.layout.conversation_item_footer_outgoing; + isOutgoing = true; + } + + inflate(getContext(), contentId, this); + + dateView = findViewById(R.id.footer_date); + simView = findViewById(R.id.footer_sim_info); + timerView = findViewById(R.id.footer_expiration_timer); + insecureIndicatorView = findViewById(R.id.footer_insecure_indicator); + deliveryStatusView = findViewById(R.id.footer_delivery_status); + audioDuration = findViewById(R.id.footer_audio_duration); + revealDot = findViewById(R.id.footer_revealed_dot); + playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle); + + if (typedArray != null) { setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white))); setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white))); setRevealDotColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_reveal_dot_color, getResources().getColor(R.color.core_white))); typedArray.recycle(); } + + dateView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + if (oldLeft != left || oldRight != right) { + notifyTouchDelegateChanged(getPlaybackSpeedToggleTouchDelegateRect(), playbackSpeedToggleTextView); + } + }); + } + + public void setOnTouchDelegateChangedListener(@Nullable OnTouchDelegateChangedListener onTouchDelegateChangedListener) { + this.onTouchDelegateChangedListener = onTouchDelegateChangedListener; } @Override @@ -108,6 +150,20 @@ public class ConversationItemFooter extends LinearLayout { audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60)); } + public void setPlaybackSpeedListener(@Nullable PlaybackSpeedToggleTextView.PlaybackSpeedListener playbackSpeedListener) { + playbackSpeedToggleTextView.setPlaybackSpeedListener(playbackSpeedListener); + } + + public void setAudioPlaybackSpeed(float playbackSpeed, boolean isPlaying) { + if (isPlaying) { + showPlaybackSpeedToggle(); + } else { + hidePlaybackSpeedToggle(); + } + + playbackSpeedToggleTextView.setCurrentSpeed(playbackSpeed); + } + public void setTextColor(int color) { dateView.setTextColor(color); simView.setTextColor(color); @@ -155,6 +211,84 @@ public class ConversationItemFooter extends LinearLayout { } } + private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) { + if (onTouchDelegateChangedListener != null) { + onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate); + } + } + + private void showPlaybackSpeedToggle() { + if (hasShrunkDate) { + return; + } + + hasShrunkDate = true; + + playbackSpeedToggleTextView.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(150L) + .setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + playbackSpeedToggleTextView.setClickable(true); + } + }); + + if (isOutgoing) { + dateView.setMaxWidth(ViewUtil.dpToPx(28)); + } else { + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(this); + constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, ViewUtil.dpToPx(40)); + constraintSet.applyTo(this); + } + } + + private void hidePlaybackSpeedToggle() { + if (!hasShrunkDate) { + return; + } + + hasShrunkDate = false; + + playbackSpeedToggleTextView.animate() + .alpha(0f) + .scaleX(0.5f) + .scaleY(0.5f) + .setDuration(150L).setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + playbackSpeedToggleTextView.setClickable(false); + playbackSpeedToggleTextView.clearRequestedSpeed(); + } + }); + + if (isOutgoing) { + dateView.setMaxWidth(Integer.MAX_VALUE); + } else { + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(this); + constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, -1); + constraintSet.applyTo(this); + } + } + + private @NonNull Rect getPlaybackSpeedToggleTouchDelegateRect() { + playbackSpeedToggleTextView.getHitRect(speedToggleHitRect); + + int widthOffset = (touchTargetSize - speedToggleHitRect.width()) / 2; + int heightOffset = (touchTargetSize - speedToggleHitRect.height()) / 2; + + speedToggleHitRect.top -= heightOffset; + speedToggleHitRect.left -= widthOffset; + speedToggleHitRect.right += widthOffset; + speedToggleHitRect.bottom += heightOffset; + + return speedToggleHitRect; + } + private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { dateView.forceLayout(); if (messageRecord.isFailed()) { @@ -189,7 +323,7 @@ public class ConversationItemFooter extends LinearLayout { simView.setText(getContext().getString(R.string.ConversationItem_from_s, subscriptionInfo.get().getDisplayName())); simView.setVisibility(View.VISIBLE); } else if (subscriptionInfo.isPresent()) { - simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName())); + simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName())); simView.setVisibility(View.VISIBLE); } else { simView.setVisibility(View.GONE); @@ -218,7 +352,7 @@ public class ConversationItemFooter extends LinearLayout { boolean mms = messageRecord.isMms(); if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id); - else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id); + else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id); expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn()); }); @@ -245,7 +379,7 @@ public class ConversationItemFooter extends LinearLayout { deliveryStatusView.setNone(); } } else { - if (!messageRecord.isOutgoing()) { + if (!messageRecord.isOutgoing()) { deliveryStatusView.setNone(); } else if (messageRecord.isPending()) { deliveryStatusView.setPending(); @@ -264,11 +398,6 @@ public class ConversationItemFooter extends LinearLayout { MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord; if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) { - if (messageRecord.isOutgoing()) { - moveAudioViewsForOutgoing(); - } else { - moveAudioViewsForIncoming(); - } showAudioDurationViews(); if (messageRecord.getViewedReceiptCount() > 0) { @@ -284,41 +413,19 @@ public class ConversationItemFooter extends LinearLayout { } } - private void moveAudioViewsForOutgoing() { - removeView(audioSpace); - removeView(audioDuration); - removeView(revealDot); - addView(audioSpace, 0); - addView(revealDot, 0); - addView(audioDuration, 0); - - int padStart = ViewUtil.dpToPx(60); - int padLeft = ViewUtil.isLtr(this) ? padStart : 0; - int padRight = ViewUtil.isRtl(this) ? padStart : 0; - - audioDuration.setPadding(padLeft, 0, padRight, 0); - } - - private void moveAudioViewsForIncoming() { - removeView(audioSpace); - removeView(audioDuration); - removeView(revealDot); - addView(audioSpace); - addView(revealDot); - addView(audioDuration); - - audioDuration.setPadding(0, 0, 0, 0); - } - private void showAudioDurationViews() { - audioSpace.setVisibility(View.VISIBLE); - audioDuration.setVisibility(View.GONE); + audioDuration.setVisibility(View.VISIBLE); revealDot.setVisibility(View.VISIBLE); + playbackSpeedToggleTextView.setVisibility(View.VISIBLE); } private void hideAudioDurationViews() { - audioSpace.setVisibility(View.GONE); audioDuration.setVisibility(View.GONE); revealDot.setVisibility(View.GONE); + playbackSpeedToggleTextView.setVisibility(View.GONE); + } + + public interface OnTouchDelegateChangedListener { + void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/PlaybackSpeedToggleTextView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/PlaybackSpeedToggleTextView.kt new file mode 100644 index 000000000..169634059 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/PlaybackSpeedToggleTextView.kt @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.components + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.animation.DecelerateInterpolator +import androidx.appcompat.widget.AppCompatTextView +import org.thoughtcrime.securesms.R + +class PlaybackSpeedToggleTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(context, attrs, defStyleAttr) { + + private val speeds: IntArray = context.resources.getIntArray(R.array.PlaybackSpeedToggleTextView__speeds) + private val labels: Array = context.resources.getStringArray(R.array.PlaybackSpeedToggleTextView__speed_labels) + private var currentSpeedIndex = 0 + private var requestedSpeed: Float? = null + + var playbackSpeedListener: PlaybackSpeedListener? = null + + init { + text = getCurrentLabel() + super.setOnClickListener { + currentSpeedIndex = getNextSpeedIndex() + text = getCurrentLabel() + requestedSpeed = getCurrentSpeed() + + playbackSpeedListener?.onPlaybackSpeedChanged(getCurrentSpeed()) + } + + isClickable = false + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + if (isClickable) { + when (event?.action) { + MotionEvent.ACTION_DOWN -> zoomIn() + MotionEvent.ACTION_UP -> zoomOut() + MotionEvent.ACTION_CANCEL -> zoomOut() + } + } + + return super.onTouchEvent(event) + } + + fun clearRequestedSpeed() { + requestedSpeed = null + } + + fun setCurrentSpeed(speed: Float) { + if (speed == getCurrentSpeed() || (requestedSpeed != null && requestedSpeed != speed)) { + if (requestedSpeed == speed) { + requestedSpeed = null + } + + return + } + + requestedSpeed = null + + val outOf100 = (speed * 100).toInt() + val index = speeds.indexOf(outOf100) + + if (index != -1) { + currentSpeedIndex = index + text = getCurrentLabel() + } else { + throw IllegalArgumentException("Invalid Speed $speed") + } + } + + private fun getNextSpeedIndex(): Int = (currentSpeedIndex + 1) % speeds.size + + private fun getCurrentSpeed(): Float = speeds[currentSpeedIndex] / 100f + + private fun getCurrentLabel(): String = labels[currentSpeedIndex] + + private fun zoomIn() { + animate() + .setInterpolator(DecelerateInterpolator()) + .setDuration(150L) + .scaleX(1.2f) + .scaleY(1.2f) + } + + private fun zoomOut() { + animate() + .setInterpolator(DecelerateInterpolator()) + .setDuration(150L) + .scaleX(1f) + .scaleY(1f) + } + + override fun setOnClickListener(l: OnClickListener?) { + throw UnsupportedOperationException() + } + + interface PlaybackSpeedListener { + fun onPlaybackSpeedChanged(speed: Float) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java index df0ef955e..763290381 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java @@ -170,6 +170,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { } } + public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) { + if (isCurrentTrack(audioSlideUri)) { + Bundle bundle = new Bundle(); + bundle.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, playbackSpeed); + + getMediaController().sendCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, bundle, null); + } + } + private boolean isCurrentTrack(@NonNull Uri uri) { MediaMetadataCompat metadataCompat = getMediaController().getMetadata(); @@ -235,6 +244,8 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { VoiceNotePlaybackState previousState = voiceNotePlaybackState.getValue(); long position = mediaController.getPlaybackState().getPosition(); long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION); + Bundle extras = mediaController.getExtras(); + float speed = extras != null ? extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) : 1f; if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) { if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) { @@ -247,7 +258,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { } if (duration > 0 && position >= 0 && position <= duration) { - voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, position, duration, autoReset)); + voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, position, duration, autoReset, speed)); } sendEmptyMessageDelayed(0, 50); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt new file mode 100644 index 000000000..5c99e2dcd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.components.voice + +import android.os.Bundle +import android.os.ResultReceiver +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController + +class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters) : DefaultPlaybackController() { + + override fun getCommands(): Array { + return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) + } + + override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) { + if (command == VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) { + val speed = extras?.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) ?: 1f + + player.playbackParameters = PlaybackParameters(speed) + voiceNotePlaybackParameters.setSpeed(speed) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackParameters.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackParameters.java new file mode 100644 index 000000000..80c535c76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackParameters.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.os.Bundle; +import android.support.v4.media.session.MediaSessionCompat; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.PlaybackParameters; + +import org.signal.core.util.logging.Log; + +public final class VoiceNotePlaybackParameters { + + private final MediaSessionCompat mediaSessionCompat; + + VoiceNotePlaybackParameters(@NonNull MediaSessionCompat mediaSessionCompat) { + this.mediaSessionCompat = mediaSessionCompat; + } + + @NonNull PlaybackParameters getParameters() { + float speed = getSpeed(); + return new PlaybackParameters(speed); + } + + void setSpeed(float speed) { + Bundle extras = new Bundle(); + extras.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, speed); + + mediaSessionCompat.setExtras(extras); + } + + private float getSpeed() { + Bundle extras = mediaSessionCompat.getController().getExtras(); + + if (extras == null) { + return 1f; + } else { + return extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java index 833551aea..2dd393d9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java @@ -46,11 +46,12 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP public static final Uri NEXT_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-down.ogg"); public static final Uri END_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-up.ogg"); - private final Context context; - private final SimpleExoPlayer player; + private final Context context; + private final SimpleExoPlayer player; private final VoiceNoteQueueDataAdapter queueDataAdapter; private final AttachmentMediaSourceFactory mediaSourceFactory; - private final ConcatenatingMediaSource dataSource; + private final ConcatenatingMediaSource dataSource; + private final VoiceNotePlaybackParameters voiceNotePlaybackParameters; private boolean canLoadMore; private Uri latestUri = Uri.EMPTY; @@ -58,13 +59,15 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP VoiceNotePlaybackPreparer(@NonNull Context context, @NonNull SimpleExoPlayer player, @NonNull VoiceNoteQueueDataAdapter queueDataAdapter, - @NonNull AttachmentMediaSourceFactory mediaSourceFactory) + @NonNull AttachmentMediaSourceFactory mediaSourceFactory, + @NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters) { this.context = context; this.player = player; this.queueDataAdapter = queueDataAdapter; this.mediaSourceFactory = mediaSourceFactory; - this.dataSource = new ConcatenatingMediaSource(); + this.dataSource = new ConcatenatingMediaSource(); + this.voiceNotePlaybackParameters = voiceNotePlaybackParameters; } @Override @@ -119,7 +122,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP @Override public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) { if (timeline.getWindowCount() >= window) { + player.setPlayWhenReady(false); + player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters()); player.seekTo(window, (long) (player.getDuration() * progress)); + player.setPlayWhenReady(true); player.removeListener(this); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java index ba1257e1e..f0570816a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -7,6 +7,7 @@ import android.content.Intent; import android.content.IntentFilter; import android.media.AudioManager; import android.os.Bundle; +import android.os.Handler; import android.os.Process; import android.os.RemoteException; import android.support.v4.media.MediaBrowserCompat; @@ -26,6 +27,7 @@ import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; @@ -35,15 +37,13 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessageDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob; import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import java.util.Collections; @@ -54,6 +54,10 @@ import java.util.List; */ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { + private static final int ACTION_AWAIT_SPEED = 0; + + public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed"; + private static final String TAG = Log.tag(VoiceNotePlaybackService.class); private static final String EMPTY_ROOT_ID = "empty-root-id"; private static final int LOAD_MORE_THRESHOLD = 2; @@ -73,7 +77,9 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { private VoiceNoteQueueDataAdapter queueDataAdapter; private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer; private VoiceNoteProximityManager voiceNoteProximityManager; - private boolean isForegroundService; + private boolean isForegroundService; + private VoiceNotePlaybackParameters voiceNotePlaybackParameters; + private Handler handler; private final LoadControl loadControl = new DefaultLoadControl.Builder() .setBufferDurationsMs(Integer.MAX_VALUE, @@ -87,9 +93,11 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { super.onCreate(); mediaSession = new MediaSessionCompat(this, TAG); + voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession); stateBuilder = new PlaybackStateCompat.Builder() - .setActions(SUPPORTED_ACTIONS); - mediaSessionConnector = new MediaSessionConnector(mediaSession, null); + .setActions(SUPPORTED_ACTIONS) + .addCustomAction(ACTION_NEXT_PLAYBACK_SPEED, "speed", R.drawable.ic_toggle_24); + mediaSessionConnector = new MediaSessionConnector(mediaSession, new VoiceNotePlaybackController(voiceNotePlaybackParameters)); becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken()); player = ExoPlayerFactory.newSimpleInstance(this, new DefaultRenderersFactory(this), new DefaultTrackSelector(), loadControl); queueDataAdapter = new VoiceNoteQueueDataAdapter(); @@ -100,7 +108,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this); - voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory); + voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory, voiceNotePlaybackParameters); voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter); mediaSession.setPlaybackState(stateBuilder.build()); @@ -150,6 +158,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { } private class VoiceNotePlayerEventListener implements Player.EventListener { + @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { switch (playbackState) { @@ -182,9 +191,19 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { } if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) { - MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex); sendViewedReceiptForCurrentWindowIndex(); + MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex); Log.d(TAG, "onPositionDiscontinuity: current window uri: " + mediaDescriptionCompat.getMediaUri()); + + PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(currentWindowIndex); + + final float speed = playbackParameters != null ? playbackParameters.speed : 1f; + if (speed != player.getPlaybackParameters().speed) { + player.setPlayWhenReady(false); + player.setPlaybackParameters(playbackParameters); + player.seekTo(currentWindowIndex, 1); + player.setPlayWhenReady(true); + } } boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD || @@ -202,6 +221,18 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { } } + private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) { + if (isAudioMessage(currentWindowIndex)) { + return voiceNotePlaybackParameters.getParameters(); + } else { + return null; + } + } + + private boolean isAudioMessage(int currentWindowIndex) { + return currentWindowIndex % 2 == 0; + } + private void sendViewedReceiptForCurrentWindowIndex() { if (player.getPlaybackState() == Player.STATE_READY && player.getPlayWhenReady() && diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java deleted file mode 100644 index fd4658a1c..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.thoughtcrime.securesms.components.voice; - -import android.net.Uri; - -import androidx.annotation.NonNull; - -/** - * Domain-level state object representing the state of the currently playing voice note. - */ -public class VoiceNotePlaybackState { - - public static final VoiceNotePlaybackState NONE = new VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false); - - private final Uri uri; - private final long playheadPositionMillis; - private final long trackDuration; - private final boolean autoReset; - - public VoiceNotePlaybackState(@NonNull Uri uri, long playheadPositionMillis, long trackDuration, boolean autoReset) { - this.uri = uri; - this.playheadPositionMillis = playheadPositionMillis; - this.trackDuration = trackDuration; - this.autoReset = autoReset; - } - - /** - * @return Uri of the currently playing AudioSlide - */ - public Uri getUri() { - return uri; - } - - /** - * @return The last known playhead position - */ - public long getPlayheadPositionMillis() { - return playheadPositionMillis; - } - - /** - * @return The track duration in ms - */ - public long getTrackDuration() { - return trackDuration; - } - - /** - * @return true if we should reset the currently playing clip. - */ - public boolean isAutoReset() { - return autoReset; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.kt new file mode 100644 index 000000000..5d84950eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.components.voice + +import android.net.Uri + +/** + * Domain-level state object representing the state of the currently playing voice note. + */ +data class VoiceNotePlaybackState( + /** + * @return Uri of the currently playing AudioSlide + */ + val uri: Uri, + + /** + * @return The last known playhead position + */ + val playheadPositionMillis: Long, + + /** + * @return The track duration in ms + */ + val trackDuration: Long, + + /** + * @return true if we should reset the currently playing clip. + */ + val isAutoReset: Boolean, + + /** + * @return The current playback speed factor + */ + val speed: Float + +) { + companion object { + @JvmField + val NONE = VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false, 1f) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index c453b4634..65d9cdf52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -1594,6 +1594,11 @@ public class ConversationFragment extends LoggingFragment { voiceNoteMediaController.seekToPosition(uri, progress); } + @Override + public void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed) { + voiceNoteMediaController.setPlaybackSpeed(uri, speed); + } + @Override public void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), onPlaybackStartObserver); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 706238457..74b4aa65f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -42,6 +42,7 @@ import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.AttributeSet; import android.util.TypedValue; +import android.view.TouchDelegate; import android.view.View; import android.view.ViewGroup; import android.widget.RelativeLayout; @@ -61,6 +62,7 @@ import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.google.android.exoplayer2.source.MediaSource; +import org.jetbrains.annotations.NotNull; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.ConfirmIdentityDialog; @@ -76,6 +78,7 @@ import org.thoughtcrime.securesms.components.ConversationItemThumbnail; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.LinkPreviewView; import org.thoughtcrime.securesms.components.Outliner; +import org.thoughtcrime.securesms.components.PlaybackSpeedToggleTextView; import org.thoughtcrime.securesms.components.QuoteView; import org.thoughtcrime.securesms.components.SharedContactView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; @@ -119,7 +122,6 @@ import org.thoughtcrime.securesms.revealable.ViewOnceUtil; import org.thoughtcrime.securesms.stickers.StickerUrl; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DynamicTheme; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan; import org.thoughtcrime.securesms.util.LongClickMovementMethod; import org.thoughtcrime.securesms.util.Projection; @@ -203,15 +205,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private int defaultBubbleColorForWallpaper; private int measureCalls; - private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); - private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener(); - private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener); - private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener(); - private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); - private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); - private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener(); - private final UrlClickListener urlClickListener = new UrlClickListener(); - private final Rect thumbnailMaskingRect = new Rect(); + private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); + private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener(); + private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener); + private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener(); + private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); + private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); + private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener(); + private final UrlClickListener urlClickListener = new UrlClickListener(); + private final Rect thumbnailMaskingRect = new Rect(); + private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener(); private final Context context; @@ -268,6 +271,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyText.setOnLongClickListener(passthroughClickListener); bodyText.setOnClickListener(passthroughClickListener); + footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener); } @Override @@ -407,6 +411,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo public void onRecipientChanged(@NonNull Recipient modified) { if (conversationRecipient.getId().equals(modified.getId())) { setBubbleState(messageRecord, modified, modified.hasWallpaper(), colorizer); + + if (audioViewStub.resolved()) { + setAudioViewTint(messageRecord); + } } if (recipient.getId().equals(modified.getId())) { @@ -519,13 +527,15 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private void setAudioViewTint(MessageRecord messageRecord) { if (hasAudio(messageRecord)) { if (!messageRecord.isOutgoing()) { - if (DynamicTheme.isDarkTheme(context)) { - audioViewStub.get().setTint(Color.WHITE); + audioViewStub.get().setTint(getContext().getResources().getColor(R.color.conversation_item_incoming_audio_foreground_tint)); + if (hasWallpaper) { + audioViewStub.get().setProgressAndPlayBackgroundTint(getContext().getResources().getColor(R.color.conversation_item_incoming_audio_play_pause_background_tint_wallpaper)); } else { - audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60)); + audioViewStub.get().setProgressAndPlayBackgroundTint(getContext().getResources().getColor(R.color.conversation_item_incoming_audio_play_pause_background_tint_normal)); } } else { audioViewStub.get().setTint(Color.WHITE); + audioViewStub.get().setProgressAndPlayBackgroundTint(getContext().getResources().getColor(R.color.transparent_white_20)); } } } @@ -741,6 +751,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver()); } + footer.setPlaybackSpeedListener(null); + if (isViewOnceMessage(messageRecord) && !messageRecord.isRemoteDelete()) { revealableStub.get().setVisibility(VISIBLE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); @@ -823,7 +835,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); - audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, false); + audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true); audioViewStub.get().setDownloadClickListener(singleDownloadClickListener); audioViewStub.get().setOnLongClickListener(passthroughClickListener); @@ -837,6 +849,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setPlaybackSpeedListener(new AudioPlaybackSpeedToggleListener()); footer.setVisibility(VISIBLE); } else if (hasDocument(messageRecord)) { documentViewStub.get().setVisibility(View.VISIBLE); @@ -1804,6 +1817,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private final class TouchDelegateChangedListener implements ConversationItemFooter.OnTouchDelegateChangedListener { + @Override + public void onTouchDelegateChanged(@NonNull @NotNull Rect delegateRect, @NonNull @NotNull View delegateView) { + offsetDescendantRectToMyCoords(footer, delegateRect); + setTouchDelegate(new TouchDelegate(delegateRect, delegateView)); + } + } + private final class UrlClickListener implements UrlClickHandler { @Override @@ -1831,6 +1852,22 @@ public final class ConversationItem extends RelativeLayout implements BindableCo public void updateDrawState(@NonNull TextPaint ds) { } } + private final class AudioPlaybackSpeedToggleListener implements PlaybackSpeedToggleTextView.PlaybackSpeedListener { + @Override + public void onPlaybackSpeedChanged(float speed) { + if (eventListener == null || !audioViewStub.resolved()) { + return; + } + + Uri uri = audioViewStub.get().getAudioSlideUri(); + if (uri == null) { + return; + } + + eventListener.onVoiceNotePlaybackSpeedChanged(uri, speed); + } + } + private final class AudioViewCallbacks implements AudioView.Callbacks { @Override @@ -1859,6 +1896,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo throw new UnsupportedOperationException(); } + @Override + public void onSpeedChanged(float speed, boolean isPlaying) { + footer.setAudioPlaybackSpeed(speed, isPlaying); + } + @Override public void onProgressUpdated(long durationMillis, long playheadMillis) { footer.setAudioDuration(durationMillis, playheadMillis); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java index a8c025bd3..af25d26ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java @@ -576,6 +576,10 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { audioItemListener.onStopAndReset(audioUri); } + @Override + public void onSpeedChanged(float speed, boolean isPlaying) { + } + @Override public void onProgressUpdated(long durationMillis, long playheadMillis) { } diff --git a/app/src/main/res/layout/conversation_item_footer_incoming.xml b/app/src/main/res/layout/conversation_item_footer_incoming.xml new file mode 100644 index 000000000..184795a9d --- /dev/null +++ b/app/src/main/res/layout/conversation_item_footer_incoming.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/conversation_item_footer.xml b/app/src/main/res/layout/conversation_item_footer_outgoing.xml similarity index 56% rename from app/src/main/res/layout/conversation_item_footer.xml rename to app/src/main/res/layout/conversation_item_footer_outgoing.xml index e4c73d170..604d39091 100644 --- a/app/src/main/res/layout/conversation_item_footer.xml +++ b/app/src/main/res/layout/conversation_item_footer_outgoing.xml @@ -6,14 +6,19 @@ android:layout_height="wrap_content" android:gravity="center_vertical|end" android:orientation="horizontal" - tools:parentTag="org.thoughtcrime.securesms.components.ConversationItemFooter"> + tools:background="@color/green" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> @@ -22,18 +27,34 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" - android:layout_marginStart="4dp" - android:layout_marginEnd="4dp" android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@id/footer_audio_duration" + app:layout_constraintTop_toTopOf="parent" app:lottie_rawRes="@raw/lottie_played" tools:visibility="visible" /> - @@ -77,10 +110,13 @@ android:layout_width="12dp" android:layout_height="11dp" android:layout_gravity="center_vertical" - android:layout_marginStart="4dp" + android:layout_marginEnd="4dp" android:contentDescription="@string/conversation_item__secure_message_description" android:src="@drawable/ic_unlocked_white_18dp" android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/footer_delivery_status" + app:layout_constraintTop_toTopOf="parent" tools:visibility="visible" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/conversation_item_received_multimedia.xml b/app/src/main/res/layout/conversation_item_received_multimedia.xml index dcf0157c1..12a116734 100644 --- a/app/src/main/res/layout/conversation_item_received_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_received_multimedia.xml @@ -190,7 +190,7 @@ android:alpha="0.7" android:clipChildren="false" android:clipToPadding="false" - android:gravity="start" + app:footer_mode="incoming" app:footer_icon_color="@color/conversation_item_sent_text_secondary_color" app:footer_reveal_dot_color="@color/conversation_item_sent_text_secondary_color" app:footer_text_color="@color/conversation_item_sent_text_secondary_color" /> @@ -207,8 +207,8 @@ android:paddingBottom="3dp" android:clipChildren="false" android:clipToPadding="false" - android:gravity="start" android:visibility="gone" + app:footer_mode="incoming" app:footer_icon_color="@color/signal_icon_tint_secondary" app:footer_reveal_dot_color="@color/signal_icon_tint_secondary" app:footer_text_color="@color/signal_text_secondary" /> diff --git a/app/src/main/res/layout/conversation_item_received_text_only.xml b/app/src/main/res/layout/conversation_item_received_text_only.xml index 3600faca1..02523b5bc 100644 --- a/app/src/main/res/layout/conversation_item_received_text_only.xml +++ b/app/src/main/res/layout/conversation_item_received_text_only.xml @@ -121,7 +121,7 @@ android:alpha="0.7" android:clipChildren="false" android:clipToPadding="false" - android:gravity="start" + app:footer_mode="incoming" app:footer_icon_color="@color/signal_icon_tint_secondary" app:footer_reveal_dot_color="@color/signal_icon_tint_secondary" app:footer_text_color="@color/signal_text_secondary" /> @@ -135,8 +135,8 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:clipChildren="false" android:clipToPadding="false" - android:gravity="start" android:visibility="gone" + app:footer_mode="incoming" app:footer_icon_color="@color/signal_icon_tint_secondary" app:footer_reveal_dot_color="@color/signal_icon_tint_secondary" app:footer_text_color="@color/signal_text_secondary" /> diff --git a/app/src/main/res/layout/conversation_item_sent_multimedia.xml b/app/src/main/res/layout/conversation_item_sent_multimedia.xml index 03cb8c94e..0def05648 100644 --- a/app/src/main/res/layout/conversation_item_sent_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_sent_multimedia.xml @@ -141,7 +141,7 @@ android:layout_marginBottom="@dimen/message_bubble_bottom_padding" android:clipChildren="false" android:clipToPadding="false" - android:gravity="end" + app:footer_mode="outgoing" app:footer_icon_color="@color/signal_icon_tint_secondary" app:footer_reveal_dot_color="@color/signal_icon_tint_secondary" app:footer_text_color="@color/signal_text_secondary" /> @@ -158,8 +158,8 @@ android:paddingBottom="3dp" android:clipChildren="false" android:clipToPadding="false" - android:gravity="end" android:visibility="gone" + app:footer_mode="outgoing" app:footer_icon_color="@color/signal_icon_tint_secondary" app:footer_reveal_dot_color="@color/signal_icon_tint_secondary" app:footer_text_color="@color/signal_text_secondary" /> diff --git a/app/src/main/res/layout/conversation_item_sent_text_only.xml b/app/src/main/res/layout/conversation_item_sent_text_only.xml index 8638abf1c..baf2a43bc 100644 --- a/app/src/main/res/layout/conversation_item_sent_text_only.xml +++ b/app/src/main/res/layout/conversation_item_sent_text_only.xml @@ -72,7 +72,7 @@ android:layout_marginBottom="@dimen/message_bubble_bottom_padding" android:clipChildren="false" android:clipToPadding="false" - android:gravity="end" + app:footer_mode="outgoing" app:footer_icon_color="@color/conversation_item_sent_text_secondary_color" app:footer_reveal_dot_color="@color/conversation_item_sent_text_secondary_color" app:footer_text_color="@color/conversation_item_sent_text_secondary_color" /> @@ -86,8 +86,8 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:clipChildren="false" android:clipToPadding="false" - android:gravity="end" android:visibility="gone" + app:footer_mode="outgoing" app:footer_icon_color="@color/signal_icon_tint_secondary" app:footer_reveal_dot_color="@color/signal_icon_tint_secondary" app:footer_text_color="@color/signal_text_secondary" /> diff --git a/app/src/main/res/layout/conversation_item_thumbnail.xml b/app/src/main/res/layout/conversation_item_thumbnail.xml index 3aeebe241..43082dffa 100644 --- a/app/src/main/res/layout/conversation_item_thumbnail.xml +++ b/app/src/main/res/layout/conversation_item_thumbnail.xml @@ -42,7 +42,7 @@ android:layout_marginStart="@dimen/message_bubble_horizontal_padding" android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_bottom_padding" - android:gravity="end" + app:footer_mode="outgoing" app:footer_text_color="@color/signal_text_toolbar_subtitle" app:footer_reveal_dot_color="@color/signal_text_toolbar_subtitle" app:footer_icon_color="@color/signal_text_toolbar_subtitle"/> diff --git a/app/src/main/res/layout/longmessage_bubble_received.xml b/app/src/main/res/layout/longmessage_bubble_received.xml index fdb96865f..6ad53cfa2 100644 --- a/app/src/main/res/layout/longmessage_bubble_received.xml +++ b/app/src/main/res/layout/longmessage_bubble_received.xml @@ -34,10 +34,10 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_bottom_padding" android:layout_gravity="end" - android:gravity="end" android:clipChildren="false" android:clipToPadding="false" android:alpha="0.7" + app:footer_mode="incoming" app:footer_text_color="@color/signal_text_secondary" app:footer_reveal_dot_color="@color/signal_text_secondary" app:footer_icon_color="@color/signal_icon_tint_secondary"/> diff --git a/app/src/main/res/layout/longmessage_bubble_sent.xml b/app/src/main/res/layout/longmessage_bubble_sent.xml index 4e5264cfd..02204b3b3 100644 --- a/app/src/main/res/layout/longmessage_bubble_sent.xml +++ b/app/src/main/res/layout/longmessage_bubble_sent.xml @@ -34,9 +34,9 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_bottom_padding" android:layout_gravity="end" - android:gravity="end" android:clipChildren="false" android:clipToPadding="false" + app:footer_mode="outgoing" app:footer_text_color="@color/conversation_item_sent_text_secondary_color" app:footer_reveal_dot_color="@color/conversation_item_sent_text_secondary_color" app:footer_icon_color="@color/conversation_item_sent_text_secondary_color"/> diff --git a/app/src/main/res/layout/shared_contact_view.xml b/app/src/main/res/layout/shared_contact_view.xml index 78b504629..0c019da2d 100644 --- a/app/src/main/res/layout/shared_contact_view.xml +++ b/app/src/main/res/layout/shared_contact_view.xml @@ -1,7 +1,7 @@ - + + android:orientation="horizontal" + app:footer_mode="outgoing" /> @color/core_grey_25 @color/core_grey_05 @color/core_grey_95 + @color/core_grey_15 + @color/core_grey_60 + @color/core_grey_80 @color/transparent_black_80 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 13fa00020..0a536bd07 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -401,5 +401,16 @@ @string/SoundsAndNotificationsSettingsFragment__always_notify @string/SoundsAndNotificationsSettingsFragment__do_not_notify - + + + 100 + 200 + 50 + + + @string/PlaybackSpeedToggleTextView__1x + @string/PlaybackSpeedToggleTextView__2x + @string/PlaybackSpeedToggleTextView__p5x + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index bad1a9b09..44f7296e8 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -170,6 +170,10 @@ + + + + diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index d1d370fda..dbbd1c2e8 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -80,6 +80,9 @@ @color/core_grey_60 @color/core_grey_90 @color/core_white + @color/core_grey_60 + @color/transparent_white_80 + @color/core_grey_05 @color/transparent_white_80 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f837f3c32..15810b46c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3595,6 +3595,11 @@ Recently used + + .5x + 1x + 2x +