diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java index ede64b580..59b0cb4b1 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java @@ -104,7 +104,7 @@ class AllMediaDocumentsAdapter extends StickyHeaderGridAdapter { viewHolder.audioView.setVisibility(View.VISIBLE); viewHolder.audioView.setPlaybackViewModel(playbackViewModel); - viewHolder.audioView.setAudio((AudioSlide)slide, dcMsg.getDuration()); + viewHolder.audioView.setAudio((AudioSlide)slide); viewHolder.audioView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); viewHolder.audioView.setOnLongClickListener(view -> { itemClickListener.onMediaLongClicked(dcMsg); return true; }); viewHolder.audioView.disablePlayer(!selected.isEmpty()); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 5e6b8038f..6d089d55f 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -493,12 +493,9 @@ public class ConversationItem extends BaseConversationItem if (vcardViewStub.resolved()) vcardViewStub.get().setVisibility(View.GONE); if (callViewStub.resolved()) callViewStub.get().setVisibility(View.GONE); - //noinspection ConstantConditions - int duration = messageRecord.getDuration(); - audioViewStub.get().setPlaybackViewModel(playbackViewModel); audioViewStub.get().setOnActionListener(audioPlayPauseListener); - audioViewStub.get().setAudio(new AudioSlide(context, messageRecord), duration); + audioViewStub.get().setAudio(new AudioSlide(context, messageRecord)); audioViewStub.get().setOnClickListener(passthroughClickListener); audioViewStub.get().setOnLongClickListener(passthroughClickListener); audioViewStub.get().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java index 85ea31652..409be88b5 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.components.audioplay; +import android.content.Context; +import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.Handler; import android.os.Looper; @@ -13,6 +15,13 @@ import androidx.media3.common.MediaItem; import androidx.media3.common.Player; import androidx.media3.session.MediaController; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + public class AudioPlaybackViewModel extends ViewModel { private static final String TAG = AudioPlaybackViewModel.class.getSimpleName(); @@ -20,6 +29,11 @@ public class AudioPlaybackViewModel extends ViewModel { private static final int NON_MESSAGE_AUDIO_MSG_ID = 0; // Audios not attached to a message doesn't have message id. private final MutableLiveData playbackState; + + private final MutableLiveData> durations = new MutableLiveData<>(new HashMap<>()); + private final Set extractionInProgress = new HashSet<>(); + private final ExecutorService extractionExecutor = Executors.newFixedThreadPool(2); + private @Nullable MediaController mediaController; private final Handler handler; private boolean isUserSeeking = false; @@ -73,6 +87,58 @@ public class AudioPlaybackViewModel extends ViewModel { currentState.getAudioUri() != null && !currentState.getAudioUri().equals(audioUri)); } + public LiveData> getDurations() { + return durations; + } + + public void ensureDurationLoaded(Context context, int msgId, Uri audioUri) { + // Check cache + Map currentDurations = durations.getValue(); + if (currentDurations != null && currentDurations.containsKey(msgId)) { + return; + } + + // Check extracting + synchronized (extractionInProgress) { + if (extractionInProgress.contains(msgId)) { + return; + } + extractionInProgress.add(msgId); + } + + // Extract in background + extractionExecutor.execute(() -> { + long duration = extractDurationFromAudio(context, audioUri); + + handler.post(() -> { + Map updatedDurations = new HashMap<>(durations.getValue()); + updatedDurations.put(msgId, duration); + durations.setValue(updatedDurations); + }); + + synchronized (extractionInProgress) { + extractionInProgress.remove(msgId); + } + }); + } + + private long extractDurationFromAudio(Context context, Uri audioUri) { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + try { + retriever.setDataSource(context, audioUri); + String durationStr = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_DURATION + ); + return durationStr != null ? Long.parseLong(durationStr) : 0; + } catch (Exception e) { + return 0; + } finally { + try { + retriever.release(); + } catch (Exception ignored) {} + } + } + public void pause(int msgId, Uri audioUri) { if (mediaController != null && isSameAudio(msgId, audioUri)) { mediaController.pause(); @@ -238,6 +304,7 @@ public class AudioPlaybackViewModel extends ViewModel { @Override protected void onCleared() { stopUpdateProgress(); + extractionExecutor.shutdown(); super.onCleared(); } } diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index c9003b5db..d8ac78a3a 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -22,6 +22,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.util.DateUtils; +import java.util.Map; + public class AudioView extends FrameLayout { @@ -39,10 +41,13 @@ public class AudioView extends FrameLayout { private final @NonNull View mask; private OnActionListener listener; - private int msgId; + private int msgId = -1; private Uri audioUri; + private int progress; + private int duration; private AudioPlaybackViewModel viewModel; private final Observer stateObserver = this::onPlaybackStateChanged; + private final Observer> durationObserver = this::onDurationsChanged; private boolean isPlaying; public AudioView(Context context) { @@ -63,7 +68,7 @@ public class AudioView extends FrameLayout { this.title = findViewById(R.id.title); this.mask = findViewById(R.id.interception_mask); - this.timestamp.setText("00:00"); + updateTimestamps(); // Load drawables once this.playToPauseDrawable = AnimatedVectorDrawableCompat.create( @@ -94,6 +99,9 @@ public class AudioView extends FrameLayout { if (viewModel != null) { viewModel.getPlaybackState().removeObserver(stateObserver); viewModel.getPlaybackState().observeForever(stateObserver); + + viewModel.getDurations().removeObserver(durationObserver); + viewModel.getDurations().observeForever(durationObserver); } playPauseButton.setOnClickListener(v -> { @@ -125,7 +133,8 @@ public class AudioView extends FrameLayout { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (fromUser) { - timestamp.setText(DateUtils.getFormatedDuration(progress)); + AudioView.this.progress = progress; + updateTimestamps(); } } @@ -153,6 +162,7 @@ public class AudioView extends FrameLayout { protected void onDetachedFromWindow() { if (viewModel != null) { viewModel.getPlaybackState().removeObserver(stateObserver); + viewModel.getDurations().removeObserver(durationObserver); } if (playToPauseDrawable != null) { playToPauseDrawable.clearAnimationCallbacks(); @@ -166,6 +176,7 @@ public class AudioView extends FrameLayout { public void setPlaybackViewModel(AudioPlaybackViewModel viewModel) { if (this.viewModel != null) { this.viewModel.getPlaybackState().removeObserver(stateObserver); + this.viewModel.getDurations().removeObserver(durationObserver); } // ViewModel is used directly for simplicity, since there is no reuse yet @@ -173,10 +184,11 @@ public class AudioView extends FrameLayout { if (viewModel != null) { viewModel.getPlaybackState().observeForever(stateObserver); + viewModel.getDurations().observeForever(durationObserver); } } - public void setAudio(final @NonNull AudioSlide audio, int duration) + public void setAudio(final @NonNull AudioSlide audio) { msgId = audio.getDcMsgId(); audioUri = audio.getUri(); @@ -184,7 +196,15 @@ public class AudioView extends FrameLayout { seekBar.setEnabled(true); seekBar.setProgress(0); - timestamp.setText(DateUtils.getFormatedDuration(duration)); + + // Get duration + Map durations = viewModel.getDurations().getValue(); + if (durations != null && durations.containsKey(msgId)) { + this.duration = Math.toIntExact(durations.get(msgId)); + updateTimestamps(); + } else { + viewModel.ensureDurationLoaded(getContext(), msgId, audioUri); + } if(audio.asAttachment().isVoiceNote() || !audio.getFileName().isPresent()) { title.setVisibility(View.GONE); @@ -243,13 +263,15 @@ public class AudioView extends FrameLayout { } private void updateProgress(AudioPlaybackState state) { - int duration = (int) state.getDuration(); - int position = (int) state.getCurrentPosition(); + int duration = Math.toIntExact(state.getDuration()); + int position = Math.toIntExact(state.getCurrentPosition()); if (duration > 0) { - seekBar.setMax(duration); + this.progress = position; + this.duration = duration; + updateTimestamps(); seekBar.setProgress(position); - timestamp.setText(DateUtils.getFormatedDuration(position)); + seekBar.setMax(duration); } } @@ -315,4 +337,28 @@ public class AudioView extends FrameLayout { break; } } + + private void onDurationsChanged(Map durations) { + AudioPlaybackState state = viewModel.getPlaybackState().getValue(); + + // When there is no playback happening, msgId can be -1 and audioUri is null + if (state != null && + msgId >= 0 && msgId == state.getMsgId() && + audioUri != null && audioUri.equals(state.getAudioUri())) { + return; // Is playing this message + } + + Long duration = durations.get(msgId); + if (duration != null && seekBar.getMax() <= 100) { + this.duration = Math.toIntExact(duration); + updateTimestamps(); + seekBar.setMax(this.duration); + } + } + + private void updateTimestamps() { + String progressText = DateUtils.getFormatedDuration(progress); + String durationText = DateUtils.getFormatedDuration(duration); + timestamp.setText(String.format("%s / %s", progressText, durationText)); + } } diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 79447ce33..05a10b13e 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -285,7 +285,7 @@ public class AttachmentManager { if (slide.hasAudio()) { audioView.setPlaybackViewModel(playbackViewModel); - audioView.setAudio((AudioSlide) slide, 0); + audioView.setAudio((AudioSlide) slide); removableMediaView.display(audioView, false); removableMediaView.addRemoveClickListener(v -> { playbackViewModel.stop(audioView.getMsgId(), audioView.getAudioUri());