diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java index c4c7005dc..3831324a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -9,6 +9,8 @@ import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.AppStartup; @@ -17,13 +19,15 @@ import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; -public class MainActivity extends PassphraseRequiredActivity { +public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner { public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901; private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); private final MainNavigator navigator = new MainNavigator(this); + private VoiceNoteMediaController mediaController; + public static @NonNull Intent clearTop(@NonNull Context context) { Intent intent = new Intent(context, MainActivity.class); @@ -40,6 +44,7 @@ public class MainActivity extends PassphraseRequiredActivity { super.onCreate(savedInstanceState, ready); setContentView(R.layout.main_activity); + mediaController = new VoiceNoteMediaController(this); navigator.onCreate(savedInstanceState); handleGroupLinkInIntent(getIntent()); @@ -109,4 +114,9 @@ public class MainActivity extends PassphraseRequiredActivity { CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString()); } } + + @Override + public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() { + return mediaController; + } } 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 4793aec2c..2a3f9fc4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -232,11 +232,11 @@ public final class AudioView extends FrameLayout { private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) { onDuration(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getTrackDuration()); - onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isAutoReset()); onProgress(voiceNotePlaybackState.getUri(), (double) voiceNotePlaybackState.getPlayheadPositionMillis() / voiceNotePlaybackState.getTrackDuration(), voiceNotePlaybackState.getPlayheadPositionMillis()); onSpeedChanged(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getSpeed()); + onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isPlaying(), voiceNotePlaybackState.isAutoReset()); } private void onDuration(@NonNull Uri uri, long durationMillis) { @@ -245,8 +245,8 @@ public final class AudioView extends FrameLayout { } } - private void onStart(@NonNull Uri uri, boolean autoReset) { - if (!isTarget(uri)) { + private void onStart(@NonNull Uri uri, boolean statePlaying, boolean autoReset) { + if (!isTarget(uri) || !statePlaying) { if (hasAudioUri()) { onStop(audioSlide.getUri(), autoReset); } 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 05036ba0d..d94682e1d 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 @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.voice; import android.content.ComponentName; import android.media.AudioManager; +import android.media.session.PlaybackState; import android.net.Uri; import android.os.Bundle; import android.os.Handler; @@ -9,19 +10,28 @@ import android.os.Looper; import android.os.Message; import android.os.RemoteException; import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Optional; import java.util.Objects; @@ -42,10 +52,11 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { private static final String TAG = Log.tag(VoiceNoteMediaController.class); - private MediaBrowserCompat mediaBrowser; - private AppCompatActivity activity; - private ProgressEventHandler progressEventHandler; - private MutableLiveData voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE); + private MediaBrowserCompat mediaBrowser; + private AppCompatActivity activity; + private ProgressEventHandler progressEventHandler; + private MutableLiveData voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE); + private LiveData> voiceNotePlayerViewState; private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback(); @@ -57,12 +68,44 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { null); activity.getLifecycle().addObserver(this); + + voiceNotePlayerViewState = Transformations.switchMap(voiceNotePlaybackState, playbackState -> { + if (playbackState.getClipType() instanceof VoiceNotePlaybackState.ClipType.Message) { + VoiceNotePlaybackState.ClipType.Message message = (VoiceNotePlaybackState.ClipType.Message) playbackState.getClipType(); + LiveRecipient sender = Recipient.live(message.getSenderId()); + LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId()); + LiveData name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(), + threadRecipient.getLiveDataResolved(), + (s, t) -> VoiceNoteMediaDescriptionCompatFactory.getTitle(activity, s, t, null)); + + return Transformations.map(name, displayName -> Optional.of( + new VoiceNotePlayerView.State( + playbackState.getUri(), + message.getMessageId(), + message.getThreadId(), + !playbackState.isPlaying(), + message.getSenderId(), + message.getThreadRecipientId(), + message.getMessagePosition(), + message.getTimestamp(), + displayName, + playbackState.getPlayheadPositionMillis(), + playbackState.getTrackDuration(), + playbackState.getSpeed()))); + } else { + return new DefaultValueLiveData<>(Optional.absent()); + } + }); } public LiveData getVoiceNotePlaybackState() { return voiceNotePlaybackState; } + public LiveData> getVoiceNotePlayerViewState() { + return voiceNotePlayerViewState; + } + @Override public void onStart(@NonNull LifecycleOwner owner) { mediaBrowser.connect(); @@ -94,6 +137,14 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING; } + private static boolean isPlayerPaused(@NonNull PlaybackStateCompat playbackStateCompat) { + return playbackStateCompat.getState() == PlaybackStateCompat.STATE_PAUSED; + } + + private static boolean isPlayerStopped(@NonNull PlaybackStateCompat playbackStateCompat) { + return playbackStateCompat.getState() <= PlaybackStateCompat.STATE_STOPPED; + } + private @NonNull MediaControllerCompat getMediaController() { return MediaControllerCompat.getMediaController(activity); } @@ -215,6 +266,17 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { mediaController.registerCallback(mediaControllerCompatCallback); + if (Objects.equals(voiceNotePlaybackState.getValue(), VoiceNotePlaybackState.NONE)) { + MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata(); + if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) { + VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null); + + if (newState != null) { + voiceNotePlaybackState.postValue(newState); + } + } + } + mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState()); } catch (RemoteException e) { Log.w(TAG, "onConnected: Failed to set media controller", e); @@ -222,6 +284,107 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { } } + private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) { + return mediaMetadataCompat != null && + mediaMetadataCompat.getDescription() != null && + mediaMetadataCompat.getDescription().getMediaUri() != null; + } + + private static @Nullable VoiceNotePlaybackState extractStateFromMetadata(@NonNull MediaControllerCompat mediaController, + @NonNull MediaMetadataCompat mediaMetadataCompat, + @Nullable VoiceNotePlaybackState previousState) + { + Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri()); + boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI); + 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) { + position = previousState.getPlayheadPositionMillis(); + } + + if (duration <= 0 && previousState.getTrackDuration() > 0) { + duration = previousState.getTrackDuration(); + } + } + + if (duration > 0 && position >= 0 && position <= duration) { + return new VoiceNotePlaybackState(mediaUri, + position, + duration, + autoReset, + speed, + isPlayerActive(mediaController.getPlaybackState()), + getClipType(mediaMetadataCompat.getBundle())); + } else { + return null; + } + } + + private static @Nullable VoiceNotePlaybackState constructPlaybackState(@NonNull MediaControllerCompat mediaController, + @Nullable VoiceNotePlaybackState previousState) + { + MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata(); + if (isPlayerActive(mediaController.getPlaybackState()) && + canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) + { + return extractStateFromMetadata(mediaController, mediaMetadataCompat, previousState); + } else if (isPlayerPaused(mediaController.getPlaybackState()) && + mediaMetadataCompat != null) + { + long position = mediaController.getPlaybackState().getPosition(); + long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION); + + if (previousState != null && position < duration) { + return previousState.asPaused(); + } else { + return VoiceNotePlaybackState.NONE; + } + } else { + return VoiceNotePlaybackState.NONE; + } + } + + private static @NonNull VoiceNotePlaybackState.ClipType getClipType(@Nullable Bundle mediaExtras) { + long messageId = -1L; + RecipientId senderId = RecipientId.UNKNOWN; + long messagePosition = -1L; + long threadId = -1L; + RecipientId threadRecipientId = RecipientId.UNKNOWN; + long timestamp = -1L; + + if (mediaExtras != null) { + messageId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID, -1L); + messagePosition = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION, -1L); + threadId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID, -1L); + timestamp = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_TIMESTAMP, -1L); + + String serializedSenderId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID); + if (serializedSenderId != null) { + senderId = RecipientId.from(serializedSenderId); + } + + String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID); + if (serializedThreadRecipientId != null) { + threadRecipientId = RecipientId.from(serializedThreadRecipientId); + } + } + + if (messageId != -1L) { + return new VoiceNotePlaybackState.ClipType.Message(messageId, + senderId, + threadRecipientId, + messagePosition, + threadId, + timestamp); + } else { + return VoiceNotePlaybackState.ClipType.Draft.INSTANCE; + } + } + private static class ProgressEventHandler extends Handler { private final MediaControllerCompat mediaController; @@ -238,38 +401,14 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { @Override public void handleMessage(@NonNull Message msg) { - MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata(); - if (isPlayerActive(mediaController.getPlaybackState()) && - mediaMetadataCompat != null && - mediaMetadataCompat.getDescription() != null && - mediaMetadataCompat.getDescription().getMediaUri() != null) - { + VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue()); - Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri()); - boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI); - 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) { - position = previousState.getPlayheadPositionMillis(); - } - - if (duration <= 0 && previousState.getTrackDuration() > 0) { - duration = previousState.getTrackDuration(); - } - } - - if (duration > 0 && position >= 0 && position <= duration) { - voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, position, duration, autoReset, speed)); - } + if (newPlaybackState != null) { + voiceNotePlaybackState.postValue(newPlaybackState); + } + if (isPlayerActive(mediaController.getPlaybackState())) { sendEmptyMessageDelayed(0, 50); - } else { - voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE); } } } @@ -281,6 +420,10 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { notifyProgressEventHandler(); } else { clearProgressEventHandler(); + + if (isPlayerStopped(state)) { + voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE); + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaControllerOwner.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaControllerOwner.kt new file mode 100644 index 000000000..30925caa3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaControllerOwner.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.components.voice + +interface VoiceNoteMediaControllerOwner { + val voiceNoteMediaController: VoiceNoteMediaController +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java index 75a743a4d..e2ce0260a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.support.v4.media.MediaDescriptionCompat; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; @@ -33,6 +34,7 @@ class VoiceNoteMediaDescriptionCompatFactory { public static final String EXTRA_THREAD_ID = "voice.note.extra.THREAD_ID"; public static final String EXTRA_COLOR = "voice.note.extra.COLOR"; public static final String EXTRA_MESSAGE_ID = "voice.note.extra.MESSAGE_ID"; + public static final String EXTRA_MESSAGE_TIMESTAMP = "voice.note.extra.MESSAGE_TIMESTAMP"; private static final String TAG = Log.tag(VoiceNoteMediaDescriptionCompatFactory.class); @@ -110,19 +112,11 @@ class VoiceNoteMediaDescriptionCompatFactory { extras.putLong(EXTRA_THREAD_ID, threadId); extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor()); extras.putLong(EXTRA_MESSAGE_ID, messageId); + extras.putLong(EXTRA_MESSAGE_TIMESTAMP, dateReceived); NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy(); - String title; - if (preference.isDisplayContact() && threadRecipient.isGroup()) { - title = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s, - sender.getDisplayName(context), - threadRecipient.getDisplayName(context)); - } else if (preference.isDisplayContact()) { - title = sender.getDisplayName(context); - } else { - title = context.getString(R.string.MessageNotifier_signal_message); - } + String title = getTitle(context, sender, threadRecipient, preference); String subtitle = null; if (preference.isDisplayContact()) { @@ -139,4 +133,22 @@ class VoiceNoteMediaDescriptionCompatFactory { .build(); } + public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) { + NotificationPrivacyPreference preference; + if (notificationPrivacyPreference == null) { + preference = new NotificationPrivacyPreference("all"); + } else { + preference = notificationPrivacyPreference; + } + + if (preference.isDisplayContact() && threadRecipient.isGroup()) { + return context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s, + sender.getDisplayName(context), + threadRecipient.getDisplayName(context)); + } else if (preference.isDisplayContact()) { + return sender.getDisplayName(context); + } else { + return context.getString(R.string.MessageNotifier_signal_message); + } + } } 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 index 5d84950eb..daaad7e2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.components.voice import android.net.Uri +import org.thoughtcrime.securesms.recipients.RecipientId /** * Domain-level state object representing the state of the currently playing voice note. @@ -29,11 +30,37 @@ data class VoiceNotePlaybackState( /** * @return The current playback speed factor */ - val speed: Float + val speed: Float, + /** + * @return Whether we are playing or paused + */ + val isPlaying: Boolean, + + /** + * @return Information about the type this clip represents. + */ + val clipType: ClipType ) { companion object { @JvmField - val NONE = VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false, 1f) + val NONE = VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false, 1f, false, ClipType.Idle) + } + + fun asPaused(): VoiceNotePlaybackState { + return copy(isPlaying = false) + } + + sealed class ClipType { + data class Message( + val messageId: Long, + val senderId: RecipientId, + val threadRecipientId: RecipientId, + val messagePosition: Long, + val threadId: Long, + val timestamp: Long + ) : ClipType() + object Draft : ClipType() + object Idle : ClipType() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayerView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayerView.kt new file mode 100644 index 000000000..e3313af3e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayerView.kt @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.components.voice + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import com.airbnb.lottie.LottieAnimationView +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.SimpleColorFilter +import com.airbnb.lottie.model.KeyPath +import com.airbnb.lottie.value.LottieValueCallback +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.PlaybackSpeedToggleTextView +import org.thoughtcrime.securesms.recipients.RecipientId +import java.util.concurrent.TimeUnit + +private const val ANIMATE_DURATION: Long = 150L +private const val TO_PAUSE = 1 +private const val TO_PLAY = -1 + +/** + * Renders a bar at the top of Conversation list and in a conversation to allow + * playback manipulation of voice notes. + */ +class VoiceNotePlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val playPauseToggleView: LottieAnimationView + private val infoView: TextView + private val speedView: PlaybackSpeedToggleTextView + private val closeButton: View + + private var lastState: State? = null + private var playerVisible: Boolean = false + private var lottieDirection: Int = 0 + + var listener: Listener? = null + + init { + inflate(context, R.layout.voice_note_player_view, this) + + playPauseToggleView = findViewById(R.id.voice_note_player_play_pause_toggle) + infoView = findViewById(R.id.voice_note_player_info) + speedView = findViewById(R.id.voice_note_player_speed) + closeButton = findViewById(R.id.voice_note_player_close) + + val speedTouchTarget: View = findViewById(R.id.voice_note_player_speed_touch_target) + speedTouchTarget.setOnClickListener { + speedView.performClick() + } + + speedView.playbackSpeedListener = object : PlaybackSpeedToggleTextView.PlaybackSpeedListener { + override fun onPlaybackSpeedChanged(speed: Float) { + lastState?.let { + listener?.onSpeedChangeRequested(it.uri, speed) + } + } + } + + closeButton.setOnClickListener { + lastState?.let { + listener?.onCloseRequested(it.uri) + } + } + + playPauseToggleView.setOnClickListener { + lastState?.let { + if (it.isPaused) { + if (it.playbackPosition >= it.playbackDuration) { + listener?.onPlay(it.uri, it.messageId, 0.0) + } else { + listener?.onPlay(it.uri, it.messageId, it.playbackPosition.toDouble() / it.playbackDuration) + } + } else { + listener?.onPause(it.uri) + } + } + } + + post { + playPauseToggleView.addValueCallback( + KeyPath("**"), + LottieProperty.COLOR_FILTER, + LottieValueCallback(SimpleColorFilter(ContextCompat.getColor(context, R.color.signal_icon_tint_primary))) + ) + } + + if (background != null) { + background.colorFilter = SimpleColorFilter(ContextCompat.getColor(context, R.color.voice_note_player_view_background)) + } + + setOnClickListener { + lastState?.let { + listener?.onNavigateToMessage(it.threadId, it.threadRecipientId, it.senderId, it.messageTimestamp, it.messagePositionInThread) + } + } + } + + fun setState(state: State) { + this.lastState = state + + if (state.isPaused) { + animateToggleToPlay() + } else { + animateToggleToPause() + } + + infoView.text = context.getString(R.string.VoiceNotePlayerView__s_dot_s, state.name, formatDuration(state.playbackDuration)) + speedView.setCurrentSpeed(state.playbackSpeed) + } + + fun show() { + if (!playerVisible) { + visibility = VISIBLE + + val animation = AnimationUtils.loadAnimation(context, R.anim.slide_from_top) + animation.duration = ANIMATE_DURATION + + startAnimation(animation) + } + + playerVisible = true + } + + fun hide() { + if (playerVisible) { + val animation = AnimationUtils.loadAnimation(context, R.anim.slide_to_top) + animation.duration = ANIMATE_DURATION + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) = Unit + override fun onAnimationRepeat(animation: Animation?) = Unit + + override fun onAnimationEnd(animation: Animation?) { + visibility = GONE + } + }) + + startAnimation(animation) + } + + playerVisible = false + } + + private fun formatDuration(duration: Long): String { + val secs = TimeUnit.MILLISECONDS.toSeconds(duration) + + return resources.getString(R.string.AudioView_duration, secs / 60, secs % 60) + } + + private fun animateToggleToPlay() { + startLottieAnimation(TO_PLAY) + } + + private fun animateToggleToPause() { + startLottieAnimation(TO_PAUSE) + } + + private fun startLottieAnimation(direction: Int) { + if (lottieDirection == direction) { + return + } + + lottieDirection = direction + playPauseToggleView.pauseAnimation() + playPauseToggleView.speed = (direction * 2).toFloat() + playPauseToggleView.resumeAnimation() + } + + data class State( + val uri: Uri, + val messageId: Long, + val threadId: Long, + val isPaused: Boolean, + val senderId: RecipientId, + val threadRecipientId: RecipientId, + val messagePositionInThread: Long, + val messageTimestamp: Long, + val name: String, + val playbackPosition: Long, + val playbackDuration: Long, + val playbackSpeed: Float + ) + + interface Listener { + fun onPlay(uri: Uri, messageId: Long, position: Double) + fun onPause(uri: Uri) + fun onCloseRequested(uri: Uri) + fun onSpeedChangeRequested(uri: Uri, speed: Float) + fun onNavigateToMessage(threadId: Long, threadRecipientId: RecipientId, senderId: RecipientId, messageSentAt: Long, messagePositionInThread: Long) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 80087bfaf..a7418ccea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -135,6 +135,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.ConversationS import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView; import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; @@ -380,6 +381,7 @@ public class ConversationActivity extends PassphraseRequiredActivity private MenuItem searchViewItem; private MessageRequestsBottomView messageRequestBottomView; private ConversationReactionDelegate reactionDelegate; + private Stub voiceNotePlayerViewStub; private AttachmentManager attachmentManager; private AudioRecorder audioRecorder; @@ -2011,6 +2013,7 @@ public class ConversationActivity extends PassphraseRequiredActivity mentionsSuggestions = ViewUtil.findStubById(this, R.id.conversation_mention_suggestions_stub); wallpaper = findViewById(R.id.conversation_wallpaper); wallpaperDim = findViewById(R.id.conversation_wallpaper_dim); + voiceNotePlayerViewStub = ViewUtil.findStubById(this, R.id.voice_note_player_stub); ImageButton quickCameraToggle = findViewById(R.id.quick_camera_toggle); ImageButton inlineAttachmentButton = findViewById(R.id.inline_attachment_button); @@ -2080,6 +2083,18 @@ public class ConversationActivity extends PassphraseRequiredActivity reactionDelegate.setOnReactionSelectedListener(this); joinGroupCallButton.setOnClickListener(v -> handleVideo(getRecipient())); + + voiceNoteMediaController.getVoiceNotePlayerViewState().observe(this, state -> { + if (state.isPresent()) { + if (!voiceNotePlayerViewStub.resolved()) { + voiceNotePlayerViewStub.get().setListener(new VoiceNotePlayerViewListener()); + } + voiceNotePlayerViewStub.get().show(); + voiceNotePlayerViewStub.get().setState(state.get()); + } else if (voiceNotePlayerViewStub.resolved()) { + voiceNotePlayerViewStub.get().hide(); + } + }); } private void updateWallpaper(@Nullable ChatWallpaper chatWallpaper) { @@ -3984,6 +3999,39 @@ public class ConversationActivity extends PassphraseRequiredActivity } } + private final class VoiceNotePlayerViewListener implements VoiceNotePlayerView.Listener { + @Override + public void onCloseRequested(@NonNull Uri uri) { + voiceNoteMediaController.stopPlaybackAndReset(uri); + } + + @Override + public void onSpeedChangeRequested(@NonNull Uri uri, float speed) { + voiceNoteMediaController.setPlaybackSpeed(uri, speed); + } + + @Override + public void onPlay(@NonNull Uri uri, long messageId, double position) { + voiceNoteMediaController.startSinglePlayback(uri, messageId, position); + } + + @Override + public void onPause(@NonNull Uri uri) { + voiceNoteMediaController.pausePlayback(uri); + } + + @Override + public void onNavigateToMessage(long threadId, @NonNull RecipientId threadRecipientId, @NonNull RecipientId senderId, long messageTimestamp, long messagePositionInThread) { + if (threadId != ConversationActivity.this.threadId) { + startActivity(ConversationIntents.createBuilder(ConversationActivity.this, threadRecipientId, threadId) + .withStartingPosition((int) messagePositionInThread) + .build()); + } else { + fragment.jumpToMessage(senderId, messageTimestamp, () -> { }); + } + } + } + private void presentMessageRequestState(@Nullable MessageRequestViewModel.MessageData messageData) { if (!Util.isEmpty(viewModel.getArgs().getDraftText()) || viewModel.getArgs().getMedia() != null || diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 627962033..087d9ca7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -27,6 +27,7 @@ import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; @@ -57,6 +58,8 @@ import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; import androidx.core.content.res.ResourcesCompat; import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; @@ -89,11 +92,11 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView; import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView; import org.thoughtcrime.securesms.conversation.ConversationFragment; import org.thoughtcrime.securesms.conversationlist.model.Conversation; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.search.MessageResult; -import org.thoughtcrime.securesms.search.SearchResult; import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; @@ -119,6 +122,9 @@ import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsPar import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.search.MessageResult; +import org.thoughtcrime.securesms.search.SearchResult; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.storage.StorageSyncHelper; @@ -188,6 +194,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode private SnapToTopDataObserver snapToTopDataObserver; private Drawable archiveDrawable; private AppForegroundObserver.Listener appForegroundObserver; + private VoiceNoteMediaControllerOwner mediaControllerOwner; + private Stub voiceNotePlayerViewStub; private Stopwatch startupStopwatch; @@ -195,6 +203,17 @@ public class ConversationListFragment extends MainFragment implements ActionMode return new ConversationListFragment(); } + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof VoiceNoteMediaControllerOwner) { + mediaControllerOwner = (VoiceNoteMediaControllerOwner) context; + } else { + throw new ClassCastException("Expected context to be a Listener"); + } + } + @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); @@ -223,6 +242,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode searchToolbar = new Stub<>(view.findViewById(R.id.search_toolbar)); megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container)); paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification)); + voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player)); Toolbar toolbar = getToolbar(view); toolbar.setVisibility(View.VISIBLE); @@ -257,6 +277,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode initializeListAdapters(); initializeTypingObserver(); initializeSearchListener(); + initializeVoiceNotePlayer(); RatingManager.showRatingDialogIfNecessary(requireContext()); @@ -507,6 +528,21 @@ public class ConversationListFragment extends MainFragment implements ActionMode }); } + private void initializeVoiceNotePlayer() { + mediaControllerOwner.getVoiceNoteMediaController().getVoiceNotePlayerViewState().observe(getViewLifecycleOwner(), state -> { + if (state.isPresent()) { + if (!voiceNotePlayerViewStub.resolved()) { + voiceNotePlayerViewStub.get().setListener(new VoiceNotePlayerViewListener()); + } + + voiceNotePlayerViewStub.get().setState(state.get()); + voiceNotePlayerViewStub.get().show(); + } else if (voiceNotePlayerViewStub.resolved()) { + voiceNotePlayerViewStub.get().hide(); + } + }); + } + private void initializeListAdapters() { defaultAdapter = new ConversationListAdapter(GlideApp.with(this), this); searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault()); @@ -1282,6 +1318,36 @@ public class ConversationListFragment extends MainFragment implements ActionMode } } } + + private final class VoiceNotePlayerViewListener implements VoiceNotePlayerView.Listener { + + @Override + public void onCloseRequested(@NonNull Uri uri) { + if (voiceNotePlayerViewStub.resolved()) { + mediaControllerOwner.getVoiceNoteMediaController().stopPlaybackAndReset(uri); + } + } + + @Override + public void onSpeedChangeRequested(@NonNull Uri uri, float speed) { + mediaControllerOwner.getVoiceNoteMediaController().setPlaybackSpeed(uri, speed); + } + + @Override + public void onPlay(@NonNull Uri uri, long messageId, double position) { + mediaControllerOwner.getVoiceNoteMediaController().startSinglePlayback(uri, messageId, position); + } + + @Override + public void onPause(@NonNull Uri uri) { + mediaControllerOwner.getVoiceNoteMediaController().pausePlayback(uri); + } + + @Override + public void onNavigateToMessage(long threadId, @NonNull RecipientId threadRecipientId, @NonNull RecipientId senderId, long messageSentAt, long messagePositionInThread) { + MainNavigator.get(requireActivity()).goToConversation(threadRecipientId, threadId, ThreadDatabase.DistributionTypes.DEFAULT, (int) messagePositionInThread); + } + } } diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml index 07512068c..bb6a3ddc7 100644 --- a/app/src/main/res/layout/conversation_activity.xml +++ b/app/src/main/res/layout/conversation_activity.xml @@ -205,6 +205,13 @@ app:layout_constraintStart_toStartOf="@id/parent_start_guideline" app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"> + + + + + app:constraint_referenced_ids="reminder,payments_notification,voice_note_player" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/voice_note_player_view.xml b/app/src/main/res/layout/voice_note_player_view.xml new file mode 100644 index 000000000..845628df8 --- /dev/null +++ b/app/src/main/res/layout/voice_note_player_view.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index eebfe37be..b9d587565 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -152,4 +152,7 @@ @color/core_grey_65 @color/core_grey_25 @color/core_grey_65 + + @color/core_grey_80 + @color/core_grey_65 diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index dbbd1c2e8..09ea4a7f0 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -152,4 +152,7 @@ @color/core_grey_05 @color/core_grey_60 @color/core_white + + @color/core_grey_02 + @color/transparent_black_08 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac0c9c2cc..d71718b2f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3606,6 +3606,9 @@ 1x 2x + + %1$s · %2$s +