Add duration extraction for audio files

This commit is contained in:
wch423
2026-02-16 18:17:00 +01:00
parent a1d21d8562
commit dec5879919
5 changed files with 125 additions and 15 deletions
@@ -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());
@@ -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);
@@ -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<AudioPlaybackState> playbackState;
private final MutableLiveData<Map<Integer, Long>> durations = new MutableLiveData<>(new HashMap<>());
private final Set<Integer> 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<Map<Integer, Long>> getDurations() {
return durations;
}
public void ensureDurationLoaded(Context context, int msgId, Uri audioUri) {
// Check cache
Map<Integer, Long> 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<Integer, Long> 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();
}
}
@@ -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<AudioPlaybackState> stateObserver = this::onPlaybackStateChanged;
private final Observer<Map<Integer, Long>> 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<Integer, Long> 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<Integer, Long> 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));
}
}
@@ -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());