Upgrade to AndroidX Media3.
This commit is contained in:
parent
4cbcee85d6
commit
11cfe5ee82
55 changed files with 1508 additions and 1594 deletions
|
@ -511,7 +511,7 @@ dependencies {
|
|||
implementation libs.google.play.services.maps
|
||||
implementation libs.google.play.services.auth
|
||||
|
||||
implementation libs.bundles.exoplayer
|
||||
implementation libs.bundles.media3
|
||||
|
||||
implementation libs.conscrypt.android
|
||||
implementation libs.signal.aesgcmprovider
|
||||
|
|
|
@ -1134,9 +1134,11 @@
|
|||
|
||||
<service
|
||||
android:name=".components.voice.VoiceNotePlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.audio.AudioCapabilities
|
||||
import com.google.android.exoplayer2.audio.AudioSink
|
||||
import com.google.android.exoplayer2.audio.DefaultAudioSink
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import org.signal.core.util.logging.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
|
@ -12,6 +14,7 @@ import java.nio.ByteBuffer
|
|||
* It does eventually recover, but it needs to be given ample opportunity to.
|
||||
* This class wraps the final DefaultAudioSink to provide exactly that functionality.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class RetryableInitAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
|
|
|
@ -1,565 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
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.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
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 java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Encapsulates control of voice note playback from an Activity component.
|
||||
* <p>
|
||||
* This class assumes that it will be created within the scope of Activity#onCreate
|
||||
* <p>
|
||||
* The workhorse of this repository is the ProgressEventHandler, which will supply a
|
||||
* steady stream of update events to the set callback.
|
||||
*/
|
||||
public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
public static final String EXTRA_THREAD_ID = "voice.note.thread_id";
|
||||
public static final String EXTRA_MESSAGE_ID = "voice.note.message_id";
|
||||
public static final String EXTRA_PROGRESS = "voice.note.playhead";
|
||||
public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
|
||||
|
||||
private MediaBrowserCompat mediaBrowser;
|
||||
private FragmentActivity activity;
|
||||
private ProgressEventHandler progressEventHandler;
|
||||
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
|
||||
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
|
||||
private VoiceNoteProximityWakeLockManager voiceNoteProximityWakeLockManager;
|
||||
private boolean isMediaBrowserCreationPostponed;
|
||||
|
||||
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
|
||||
|
||||
public VoiceNoteMediaController(@NonNull FragmentActivity activity) {
|
||||
this(activity, false);
|
||||
}
|
||||
|
||||
public VoiceNoteMediaController(@NonNull FragmentActivity activity, boolean postponeMediaBrowserCreation) {
|
||||
this.activity = activity;
|
||||
this.isMediaBrowserCreationPostponed = postponeMediaBrowserCreation;
|
||||
|
||||
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<String> name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(),
|
||||
threadRecipient.getLiveDataResolved(),
|
||||
(s, t) -> VoiceNoteMediaItemFactory.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.empty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void ensureMediaBrowser() {
|
||||
if (mediaBrowser != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mediaBrowser = new MediaBrowserCompat(activity,
|
||||
new ComponentName(activity, VoiceNotePlaybackService.class),
|
||||
new ConnectionCallback(),
|
||||
null);
|
||||
}
|
||||
|
||||
public LiveData<VoiceNotePlaybackState> getVoiceNotePlaybackState() {
|
||||
return voiceNotePlaybackState;
|
||||
}
|
||||
|
||||
public LiveData<Optional<VoiceNotePlayerView.State>> getVoiceNotePlayerViewState() {
|
||||
return voiceNotePlayerViewState;
|
||||
}
|
||||
|
||||
public void finishPostpone() {
|
||||
isMediaBrowserCreationPostponed = false;
|
||||
if (activity != null && mediaBrowser == null && activity.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
ensureMediaBrowser();
|
||||
mediaBrowser.disconnect();
|
||||
mediaBrowser.connect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume(@NonNull LifecycleOwner owner) {
|
||||
if (mediaBrowser == null && isMediaBrowserCreationPostponed) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureMediaBrowser();
|
||||
mediaBrowser.disconnect();
|
||||
mediaBrowser.connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause(@NonNull LifecycleOwner owner) {
|
||||
clearProgressEventHandler();
|
||||
|
||||
if (MediaControllerCompat.getMediaController(activity) != null) {
|
||||
MediaControllerCompat.getMediaController(activity).unregisterCallback(mediaControllerCompatCallback);
|
||||
}
|
||||
|
||||
if (mediaBrowser != null) {
|
||||
mediaBrowser.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(@NonNull LifecycleOwner owner) {
|
||||
if (voiceNoteProximityWakeLockManager != null) {
|
||||
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease();
|
||||
voiceNoteProximityWakeLockManager.unregisterFromLifecycle();
|
||||
voiceNoteProximityWakeLockManager = null;
|
||||
}
|
||||
|
||||
activity.getLifecycle().removeObserver(this);
|
||||
activity = null;
|
||||
}
|
||||
|
||||
private static boolean isPlayerActive(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_BUFFERING ||
|
||||
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 @Nullable MediaControllerCompat getMediaController() {
|
||||
if (activity != null) {
|
||||
return MediaControllerCompat.getMediaController(activity);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, false);
|
||||
}
|
||||
|
||||
public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, true);
|
||||
}
|
||||
|
||||
public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) {
|
||||
startPlayback(draftUri, -1, threadId, progress, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Media service to begin playback of a given audio slide. If the audio
|
||||
* slide is currently playing, we jump to the desired position and then begin playback.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the desired audio slide
|
||||
* @param messageId The Message id of the given audio slide
|
||||
* @param progress The desired progress % to seek to.
|
||||
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
|
||||
*/
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called startPlayback before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
getMediaController().getTransportControls().seekTo((long) (duration * progress));
|
||||
getMediaController().getTransportControls().play();
|
||||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putDouble(EXTRA_PROGRESS, progress);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback);
|
||||
|
||||
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Media service to resume playback of a given audio slide. If the audio slide is not
|
||||
* currently paused, playback will be started from the beginning.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the desired audio slide
|
||||
* @param messageId The Message id of the given audio slide
|
||||
*/
|
||||
public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called resumePlayback before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
getMediaController().getTransportControls().play();
|
||||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, -1L);
|
||||
extras.putDouble(EXTRA_PROGRESS, 0.0);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, true);
|
||||
|
||||
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback if the given audio slide is playing.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the audio slide to pause.
|
||||
*/
|
||||
public void pausePlayback(@NonNull Uri audioSlideUri) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called pausePlayback(uri) before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
getMediaController().getTransportControls().pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback regardless of which audio slide is playing.
|
||||
*/
|
||||
public void pausePlayback() {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called pausePlayback before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
getMediaController().getTransportControls().pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks to a given position if th given audio slide is playing. This call
|
||||
* is ignored if the given audio slide is not currently playing.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the audio slide to seek.
|
||||
* @param progress The progress percentage to seek to.
|
||||
*/
|
||||
public void seekToPosition(@NonNull Uri audioSlideUri, double progress) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called seekToPosition before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
getMediaController().getTransportControls().pause();
|
||||
getMediaController().getTransportControls().seekTo((long) (duration * progress));
|
||||
getMediaController().getTransportControls().play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops playback if the given audio slide is playing
|
||||
*
|
||||
* @param audioSlideUri The Uri of the audio slide to stop
|
||||
*/
|
||||
public void stopPlaybackAndReset(@NonNull Uri audioSlideUri) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called stopPlaybackAndReset before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
getMediaController().getTransportControls().stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called setPlaybackSpeed before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called isCurrentTrack before controller was set. (" + getActivityName() + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
MediaMetadataCompat metadataCompat = getMediaController().getMetadata();
|
||||
|
||||
return metadataCompat != null && Objects.equals(metadataCompat.getDescription().getMediaUri(), uri);
|
||||
}
|
||||
|
||||
private void notifyProgressEventHandler() {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressEventHandler == null && activity != null) {
|
||||
progressEventHandler = new ProgressEventHandler(getMediaController(), voiceNotePlaybackState);
|
||||
progressEventHandler.sendEmptyMessage(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearProgressEventHandler() {
|
||||
if (progressEventHandler != null) {
|
||||
progressEventHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull String getActivityName() {
|
||||
if (activity == null) {
|
||||
return "Activity is null";
|
||||
} else {
|
||||
return activity.getLocalClassName();
|
||||
}
|
||||
}
|
||||
|
||||
private final class ConnectionCallback extends MediaBrowserCompat.ConnectionCallback {
|
||||
@Override
|
||||
public void onConnected() {
|
||||
MediaSessionCompat.Token token = mediaBrowser.getSessionToken();
|
||||
MediaControllerCompat mediaController = new MediaControllerCompat(activity, token);
|
||||
|
||||
MediaControllerCompat.setMediaController(activity, mediaController);
|
||||
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
|
||||
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
|
||||
|
||||
if (newState != null) {
|
||||
voiceNotePlaybackState.postValue(newState);
|
||||
} else {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
|
||||
cleanUpOldProximityWakeLockManager();
|
||||
voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController);
|
||||
|
||||
mediaController.registerCallback(mediaControllerCompatCallback);
|
||||
|
||||
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionSuspended() {
|
||||
Log.d(TAG, "Voice note MediaBrowser connection suspended.");
|
||||
cleanUpOldProximityWakeLockManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionFailed() {
|
||||
Log.d(TAG, "Voice note MediaBrowser connection failed.");
|
||||
cleanUpOldProximityWakeLockManager();
|
||||
}
|
||||
|
||||
private void cleanUpOldProximityWakeLockManager() {
|
||||
if (voiceNoteProximityWakeLockManager != null) {
|
||||
Log.d(TAG, "Session reconnected, cleaning up old wake lock manager");
|
||||
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease();
|
||||
voiceNoteProximityWakeLockManager.unregisterFromLifecycle();
|
||||
voiceNoteProximityWakeLockManager = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, VoiceNoteMediaItemFactory.NEXT_URI) || Objects.equals(mediaUri, VoiceNoteMediaItemFactory.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(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L);
|
||||
messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L);
|
||||
threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L);
|
||||
timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L);
|
||||
|
||||
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
|
||||
if (serializedSenderId != null) {
|
||||
senderId = RecipientId.from(serializedSenderId);
|
||||
}
|
||||
|
||||
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.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;
|
||||
private final MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState;
|
||||
|
||||
private ProgressEventHandler(@NonNull MediaControllerCompat mediaController,
|
||||
@NonNull MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState)
|
||||
{
|
||||
super(Looper.getMainLooper());
|
||||
|
||||
this.mediaController = mediaController;
|
||||
this.voiceNotePlaybackState = voiceNotePlaybackState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(@NonNull Message msg) {
|
||||
VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue());
|
||||
|
||||
if (newPlaybackState != null) {
|
||||
voiceNotePlaybackState.postValue(newPlaybackState);
|
||||
}
|
||||
|
||||
if (isPlayerActive(mediaController.getPlaybackState())) {
|
||||
sendEmptyMessageDelayed(0, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class MediaControllerCompatCallback extends MediaControllerCompat.Callback {
|
||||
@Override
|
||||
public void onPlaybackStateChanged(PlaybackStateCompat state) {
|
||||
if (isPlayerActive(state)) {
|
||||
notifyProgressEventHandler();
|
||||
} else {
|
||||
clearProgressEventHandler();
|
||||
|
||||
if (isPlayerStopped(state)) {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,450 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.switchMap
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.RequestMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
import org.signal.core.util.logging.Log
|
||||
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 java.util.Optional
|
||||
|
||||
/**
|
||||
* This is a lifecycle-aware wrapper for the [MediaController].
|
||||
* Its main responsibilities are broadcasting playback state through [LiveData],
|
||||
* and resolving metadata values for a audio clip's URI into a [MediaItem] that media3 can understand.
|
||||
*/
|
||||
class VoiceNoteMediaController(val activity: FragmentActivity, private var postponeMediaControllerCreation: Boolean) : DefaultLifecycleObserver {
|
||||
|
||||
val voiceNotePlaybackState = MutableLiveData(VoiceNotePlaybackState.NONE)
|
||||
val voiceNotePlayerViewState: LiveData<Optional<VoiceNotePlayerView.State>>
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
private var mediaControllerProperty: MediaController? = null
|
||||
private lateinit var voiceNoteProximityWakeLockManager: VoiceNoteProximityWakeLockManager
|
||||
private var progressEventHandler: ProgressEventHandler? = null
|
||||
private var queuedPlayback: PlaybackItem? = null
|
||||
|
||||
init {
|
||||
activity.lifecycle.addObserver(this)
|
||||
|
||||
voiceNotePlayerViewState = voiceNotePlaybackState.switchMap { (uri, playheadPositionMillis, trackDuration, _, speed, isPlaying, clipType): VoiceNotePlaybackState ->
|
||||
if (clipType is VoiceNotePlaybackState.ClipType.Message) {
|
||||
val (messageId, senderId, threadRecipientId, messagePosition, threadId, timestamp) = clipType
|
||||
val sender = Recipient.live(senderId)
|
||||
val threadRecipient = Recipient.live(threadRecipientId)
|
||||
val name = LiveDataUtil.combineLatest(
|
||||
sender.liveDataResolved,
|
||||
threadRecipient.liveDataResolved
|
||||
) { s: Recipient, t: Recipient -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null) }
|
||||
|
||||
return@switchMap name.map<String, Optional<VoiceNotePlayerView.State>> { displayName: String ->
|
||||
Optional.of<VoiceNotePlayerView.State>(
|
||||
VoiceNotePlayerView.State(
|
||||
uri,
|
||||
messageId,
|
||||
threadId,
|
||||
!isPlaying,
|
||||
senderId,
|
||||
threadRecipientId,
|
||||
messagePosition,
|
||||
timestamp,
|
||||
displayName,
|
||||
playheadPositionMillis,
|
||||
trackDuration,
|
||||
speed
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return@switchMap DefaultValueLiveData<Optional<VoiceNotePlayerView.State>>(Optional.empty<VoiceNotePlayerView.State>())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
super.onStart(owner)
|
||||
if (mediaControllerProperty == null && postponeMediaControllerCreation) {
|
||||
Log.i(TAG, "Postponing media controller creation. (${activity.localClassName}})")
|
||||
return
|
||||
}
|
||||
|
||||
createMediaControllerAsync()
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
super.onResume(owner)
|
||||
|
||||
progressEventHandler?.sendEmptyMessage(0)
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
clearProgressEventHandler()
|
||||
super.onPause(owner)
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
super.onStop(owner)
|
||||
mediaControllerProperty?.release()
|
||||
mediaControllerProperty = null
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease()
|
||||
voiceNoteProximityWakeLockManager.unregisterFromLifecycle()
|
||||
activity.lifecycle.removeObserver(this)
|
||||
super.onDestroy(owner)
|
||||
}
|
||||
|
||||
fun finishPostpone() {
|
||||
if (mediaControllerProperty == null && postponeMediaControllerCreation && activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
Log.i(TAG, "Finishing postponed media controller creation. (${activity.localClassName}})")
|
||||
createMediaControllerAsync()
|
||||
} else {
|
||||
Log.w(TAG, "Could not finish postponed media controller creation! (${activity.localClassName}})")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaControllerAsync() {
|
||||
val voiceNotePlaybackServiceSessionToken = SessionToken(activity, ComponentName(activity, VoiceNotePlaybackService::class.java))
|
||||
val mediaControllerBuilder = MediaController.Builder(activity, voiceNotePlaybackServiceSessionToken)
|
||||
Observable.fromFuture(mediaControllerBuilder.buildAsync())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
initializeMediaController(it)
|
||||
}
|
||||
.addTo(disposables)
|
||||
}
|
||||
|
||||
private fun initializeMediaController(uninitializedMediaController: MediaController) {
|
||||
postponeMediaControllerCreation = false
|
||||
|
||||
voiceNoteProximityWakeLockManager = VoiceNoteProximityWakeLockManager(activity, uninitializedMediaController)
|
||||
uninitializedMediaController.addListener(PlaybackStateListener())
|
||||
Log.d(TAG, "MediaController successfully initialized. (${activity.localClassName})")
|
||||
mediaControllerProperty = uninitializedMediaController
|
||||
queuedPlayback?.let { startPlayback(it) }
|
||||
queuedPlayback = null
|
||||
notifyProgressEventHandler()
|
||||
}
|
||||
|
||||
private fun notifyProgressEventHandler() {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (${activity.localClassName})")
|
||||
return
|
||||
}
|
||||
if (progressEventHandler == null) {
|
||||
progressEventHandler = ProgressEventHandler(mediaController, voiceNotePlaybackState)
|
||||
}
|
||||
progressEventHandler?.sendEmptyMessage(0)
|
||||
}
|
||||
|
||||
private fun clearProgressEventHandler() {
|
||||
progressEventHandler = null
|
||||
}
|
||||
|
||||
fun startConsecutivePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) {
|
||||
startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, false))
|
||||
}
|
||||
|
||||
fun startSinglePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) {
|
||||
startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, true))
|
||||
}
|
||||
|
||||
fun startSinglePlaybackForDraft(draftUri: Uri, threadId: Long, progress: Double) {
|
||||
startPlayback(PlaybackItem(draftUri, -1, threadId, progress, true))
|
||||
}
|
||||
|
||||
fun resumePlayback(audioSlideUri: Uri, messageId: Long) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to resume playback before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.play()
|
||||
} else {
|
||||
startSinglePlayback(audioSlideUri, messageId, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fun pausePlayback(audioSlideUri: Uri) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
pausePlayback()
|
||||
} else {
|
||||
Log.i(TAG, "Tried to pause $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to pause playback before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
mediaController.pause()
|
||||
}
|
||||
|
||||
fun seekToPosition(audioSlideUri: Uri, progress: Double) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to seekToPosition before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.seekTo((mediaController.duration * progress).toLong())
|
||||
} else {
|
||||
Log.i(TAG, "Tried to seek $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPlaybackAndReset(audioSlideUri: Uri) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to stopPlaybackAndReset before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.stop()
|
||||
} else {
|
||||
Log.i(TAG, "Tried to stop $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(audioSlideUri: Uri, playbackSpeed: Float) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to set playback speed before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.setPlaybackSpeed(playbackSpeed)
|
||||
} else {
|
||||
Log.i(TAG, "Tried to set playback speed of $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Media service to begin playback of a given audio slide. If the audio
|
||||
* slide is currently playing, we jump to the desired position and then begin playback.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the desired audio slide
|
||||
* @param messageId The Message id of the given audio slide
|
||||
* @param progress The desired progress % to seek to.
|
||||
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
|
||||
*/
|
||||
private fun startPlayback(playbackItem: PlaybackItem) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to start playback before the media controller was ready.")
|
||||
queuedPlayback = playbackItem
|
||||
return
|
||||
}
|
||||
|
||||
if (isCurrentTrack(playbackItem.audioSlideUri)) {
|
||||
val duration: Long = mediaController.duration
|
||||
mediaController.seekTo((duration * playbackItem.progress).toLong())
|
||||
mediaController.play()
|
||||
} else {
|
||||
val extras = bundleOf(
|
||||
EXTRA_MESSAGE_ID to playbackItem.messageId,
|
||||
EXTRA_THREAD_ID to playbackItem.threadId,
|
||||
EXTRA_PROGRESS to playbackItem.progress,
|
||||
EXTRA_PLAY_SINGLE to playbackItem.singlePlayback
|
||||
)
|
||||
val requestMetadata = RequestMetadata.Builder().setMediaUri(playbackItem.audioSlideUri).setExtras(extras).build()
|
||||
if (playbackItem.singlePlayback) {
|
||||
mediaController.clearMediaItems()
|
||||
}
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(playbackItem.audioSlideUri)
|
||||
.setRequestMetadata(requestMetadata).build()
|
||||
mediaController.addMediaItem(mediaItem)
|
||||
mediaController.play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrentTrack(uri: Uri): Boolean {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Called isCurrentTrack before media controller was set. (${activity.localClassName}})")
|
||||
return false
|
||||
}
|
||||
return uri == getCurrentlyPlayingUri()
|
||||
}
|
||||
|
||||
private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
|
||||
private fun getCurrentlyPlayingUri(): Uri? = mediaControllerProperty?.currentMediaItem?.requestMetadata?.mediaUri
|
||||
|
||||
inner class PlaybackStateListener : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (player.isPlaying) {
|
||||
notifyProgressEventHandler()
|
||||
} else {
|
||||
clearProgressEventHandler()
|
||||
if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressEventHandler(
|
||||
private val mediaController: MediaController,
|
||||
private val voiceNotePlaybackState: MutableLiveData<VoiceNotePlaybackState>
|
||||
) : Handler(Looper.getMainLooper()) {
|
||||
override fun handleMessage(msg: Message) {
|
||||
val newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.value)
|
||||
voiceNotePlaybackState.postValue(newPlaybackState)
|
||||
val playerActive = mediaController.isPlaying
|
||||
if (playerActive) {
|
||||
sendEmptyMessageDelayed(0, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VoiceNoteMediaController::class.java)
|
||||
|
||||
var EXTRA_THREAD_ID = "voice.note.thread_id"
|
||||
var EXTRA_MESSAGE_ID = "voice.note.message_id"
|
||||
var EXTRA_PROGRESS = "voice.note.playhead"
|
||||
var EXTRA_PLAY_SINGLE = "voice.note.play.single"
|
||||
|
||||
@JvmStatic
|
||||
private fun constructPlaybackState(
|
||||
mediaController: MediaController,
|
||||
previousState: VoiceNotePlaybackState?
|
||||
): VoiceNotePlaybackState {
|
||||
val mediaUri = mediaController.currentMediaItem?.requestMetadata?.mediaUri
|
||||
return if (mediaController.isPlaying &&
|
||||
mediaUri != null
|
||||
) {
|
||||
extractStateFromMetadata(mediaController, mediaUri, previousState)
|
||||
} else if (mediaController.playbackState == Player.STATE_READY && !mediaController.playWhenReady) {
|
||||
val position = mediaController.currentPosition
|
||||
val duration = mediaController.contentDuration
|
||||
if (previousState != null && position < duration) {
|
||||
previousState.asPaused()
|
||||
} else {
|
||||
VoiceNotePlaybackState.NONE
|
||||
}
|
||||
} else {
|
||||
VoiceNotePlaybackState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun extractStateFromMetadata(
|
||||
mediaController: MediaController,
|
||||
mediaUri: Uri,
|
||||
previousState: VoiceNotePlaybackState?
|
||||
): VoiceNotePlaybackState {
|
||||
val speed = mediaController.playbackParameters.speed
|
||||
var duration = mediaController.contentDuration
|
||||
val mediaMetadata = mediaController.mediaMetadata
|
||||
var position = mediaController.currentPosition
|
||||
val autoReset = mediaUri == VoiceNoteMediaItemFactory.NEXT_URI || mediaUri == VoiceNoteMediaItemFactory.END_URI
|
||||
if (previousState != null && mediaUri == previousState.uri) {
|
||||
if (position < 0 && previousState.playheadPositionMillis >= 0) {
|
||||
position = previousState.playheadPositionMillis
|
||||
}
|
||||
if (duration <= 0 && previousState.trackDuration > 0) {
|
||||
duration = previousState.trackDuration
|
||||
}
|
||||
}
|
||||
return if (duration > 0 && position >= 0 && position <= duration) {
|
||||
VoiceNotePlaybackState(
|
||||
mediaUri,
|
||||
position,
|
||||
duration,
|
||||
autoReset,
|
||||
speed,
|
||||
mediaController.isPlaying,
|
||||
getClipType(mediaMetadata.extras)
|
||||
)
|
||||
} else {
|
||||
VoiceNotePlaybackState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun getClipType(mediaExtras: Bundle?): VoiceNotePlaybackState.ClipType {
|
||||
var messageId = -1L
|
||||
var senderId = RecipientId.UNKNOWN
|
||||
var messagePosition = -1L
|
||||
var threadId = -1L
|
||||
var threadRecipientId = RecipientId.UNKNOWN
|
||||
var timestamp = -1L
|
||||
if (mediaExtras != null) {
|
||||
messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L)
|
||||
messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L)
|
||||
threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L)
|
||||
timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L)
|
||||
val serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID)
|
||||
if (serializedSenderId != null) {
|
||||
senderId = RecipientId.from(serializedSenderId)
|
||||
}
|
||||
val serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID)
|
||||
if (serializedThreadRecipientId != null) {
|
||||
threadRecipientId = RecipientId.from(serializedThreadRecipientId)
|
||||
}
|
||||
}
|
||||
return if (messageId != -1L) {
|
||||
VoiceNotePlaybackState.ClipType.Message(
|
||||
messageId,
|
||||
senderId!!,
|
||||
threadRecipientId!!,
|
||||
messagePosition,
|
||||
threadId,
|
||||
timestamp
|
||||
)
|
||||
} else {
|
||||
VoiceNotePlaybackState.ClipType.Draft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holder class that contains everything one might need to begin voice note playback. Useful for queueing up items to play when the media controller is being initialized.
|
||||
*/
|
||||
data class PlaybackItem(val audioSlideUri: Uri, val messageId: Long, val threadId: Long, val progress: Double, val singlePlayback: Boolean)
|
||||
}
|
|
@ -3,14 +3,12 @@ package org.thoughtcrime.securesms.components.voice;
|
|||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.MediaMetadata;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -144,22 +142,21 @@ class VoiceNoteMediaItemFactory {
|
|||
}
|
||||
|
||||
return new MediaItem.Builder()
|
||||
.setUri(audioUri)
|
||||
.setMediaMetadata(
|
||||
new MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
)
|
||||
.setTag(
|
||||
new MediaDescriptionCompat.Builder()
|
||||
.setMediaUri(audioUri)
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build())
|
||||
.build();
|
||||
.setUri(audioUri)
|
||||
.setMediaMetadata(
|
||||
new MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
)
|
||||
.setRequestMetadata(
|
||||
new MediaItem.RequestMetadata.Builder()
|
||||
.setMediaUri(audioUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) {
|
||||
|
@ -191,18 +188,16 @@ class VoiceNoteMediaItemFactory {
|
|||
}
|
||||
|
||||
private static MediaItem cloneMediaItem(MediaItem source, String mediaId, Uri uri) {
|
||||
MediaDescriptionCompat description = source.playbackProperties != null ? (MediaDescriptionCompat) source.playbackProperties.tag : null;
|
||||
Bundle requestExtras = source.requestMetadata.extras;
|
||||
return source.buildUpon()
|
||||
.setMediaId(mediaId)
|
||||
.setUri(uri)
|
||||
.setTag(
|
||||
description != null ?
|
||||
new MediaDescriptionCompat.Builder()
|
||||
.setMediaMetadata(source.mediaMetadata)
|
||||
.setRequestMetadata(
|
||||
new MediaItem.RequestMetadata.Builder()
|
||||
.setMediaUri(uri)
|
||||
.setTitle(description.getTitle())
|
||||
.setSubtitle(description.getSubtitle())
|
||||
.setExtras(description.getExtras())
|
||||
.build() : null)
|
||||
.setExtras(requestExtras)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,304 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import com.google.common.collect.ImmutableList
|
||||
import org.signal.core.util.PendingIntentFlags.cancelCurrent
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil
|
||||
import java.util.Arrays
|
||||
|
||||
/**
|
||||
* This handles all of the notification and playback APIs for playing back a voice note.
|
||||
* It integrates, using [androidx.media.app.NotificationCompat.MediaStyle], with the system's media controls.
|
||||
*/
|
||||
@OptIn(markerClass = [UnstableApi::class])
|
||||
class VoiceNoteMediaNotificationProvider(val context: Context) : MediaNotification.Provider {
|
||||
private val notificationChannel: String = NotificationChannels.getInstance().VOICE_NOTES
|
||||
private var cachedRecipientId: RecipientId? = null
|
||||
private var cachedBitmap: Bitmap? = null
|
||||
|
||||
override fun createNotification(mediaSession: MediaSession, customLayout: ImmutableList<CommandButton>, actionFactory: MediaNotification.ActionFactory, onNotificationChangedCallback: MediaNotification.Provider.Callback): MediaNotification {
|
||||
val player = mediaSession.player
|
||||
val builder = NotificationCompat.Builder(context, notificationChannel)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColorized(true)
|
||||
if (player.isCommandAvailable(Player.COMMAND_GET_METADATA)) {
|
||||
val metadata: MediaMetadata = player.mediaMetadata
|
||||
builder
|
||||
.setContentTitle(metadata.title)
|
||||
.setContentText(metadata.subtitle)
|
||||
}
|
||||
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
|
||||
val compactViewIndices: IntArray = addNotificationActions(
|
||||
mediaSession,
|
||||
getMediaButtons(
|
||||
player.availableCommands,
|
||||
customLayout,
|
||||
player.playWhenReady &&
|
||||
player.playbackState != Player.STATE_ENDED
|
||||
),
|
||||
builder,
|
||||
actionFactory
|
||||
)
|
||||
mediaStyle.setShowActionsInCompactView(*compactViewIndices)
|
||||
|
||||
if (player.isCommandAvailable(Player.COMMAND_STOP)) {
|
||||
mediaStyle.setCancelButtonIntent(
|
||||
actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong())
|
||||
)
|
||||
}
|
||||
val extras = mediaSession.player.mediaMetadata.extras
|
||||
|
||||
if (extras != null) {
|
||||
var color = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR).toInt()
|
||||
if (color == 0) {
|
||||
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor()
|
||||
}
|
||||
builder.color = color
|
||||
|
||||
val pendingIntent = createCurrentContentIntent(extras)
|
||||
builder.setContentIntent(pendingIntent)
|
||||
} else {
|
||||
Log.w(TAG, "Could not populate notification: request metadata extras were null.")
|
||||
}
|
||||
builder.setDeleteIntent(
|
||||
actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong())
|
||||
)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setStyle(mediaStyle)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(false)
|
||||
addLargeIcon(builder, extras, onNotificationChangedCallback)
|
||||
|
||||
return MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Borrowed from [DefaultMediaNotificationProvider]
|
||||
*/
|
||||
private fun addNotificationActions(
|
||||
mediaSession: MediaSession?,
|
||||
mediaButtons: ImmutableList<CommandButton>,
|
||||
builder: NotificationCompat.Builder,
|
||||
actionFactory: MediaNotification.ActionFactory
|
||||
): IntArray {
|
||||
var compactViewIndices = IntArray(3)
|
||||
val defaultCompactViewIndices = IntArray(3)
|
||||
Arrays.fill(compactViewIndices, C.INDEX_UNSET)
|
||||
Arrays.fill(defaultCompactViewIndices, C.INDEX_UNSET)
|
||||
var compactViewCommandCount = 0
|
||||
for (i in mediaButtons.indices) {
|
||||
val commandButton = mediaButtons[i]
|
||||
if (commandButton.sessionCommand != null) {
|
||||
builder.addAction(
|
||||
actionFactory.createCustomActionFromCustomCommandButton(mediaSession!!, commandButton)
|
||||
)
|
||||
} else {
|
||||
Assertions.checkState(commandButton.playerCommand != Player.COMMAND_INVALID)
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
mediaSession!!,
|
||||
IconCompat.createWithResource(context, commandButton.iconResId),
|
||||
commandButton.displayName,
|
||||
commandButton.playerCommand
|
||||
)
|
||||
)
|
||||
}
|
||||
if (compactViewCommandCount == 3) {
|
||||
continue
|
||||
}
|
||||
val compactViewIndex = commandButton.extras.getInt(
|
||||
DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX,
|
||||
C.INDEX_UNSET
|
||||
)
|
||||
if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.size) {
|
||||
compactViewCommandCount++
|
||||
compactViewIndices[compactViewIndex] = i
|
||||
} else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS ||
|
||||
commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
|
||||
) {
|
||||
defaultCompactViewIndices[0] = i
|
||||
} else if (commandButton.playerCommand == Player.COMMAND_PLAY_PAUSE) {
|
||||
defaultCompactViewIndices[1] = i
|
||||
} else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT ||
|
||||
commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM
|
||||
) {
|
||||
defaultCompactViewIndices[2] = i
|
||||
}
|
||||
}
|
||||
if (compactViewCommandCount == 0) {
|
||||
// If there is no custom configuration we use the seekPrev (if any), play/pause (if any),
|
||||
// seekNext (if any) action in compact view.
|
||||
var indexInCompactViewIndices = 0
|
||||
for (i in defaultCompactViewIndices.indices) {
|
||||
if (defaultCompactViewIndices[i] == C.INDEX_UNSET) {
|
||||
continue
|
||||
}
|
||||
compactViewIndices[indexInCompactViewIndices] = defaultCompactViewIndices[i]
|
||||
indexInCompactViewIndices++
|
||||
}
|
||||
}
|
||||
for (i in compactViewIndices.indices) {
|
||||
if (compactViewIndices[i] == C.INDEX_UNSET) {
|
||||
compactViewIndices = compactViewIndices.copyOf(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
return compactViewIndices
|
||||
}
|
||||
|
||||
/**
|
||||
* Borrowed from [DefaultMediaNotificationProvider]
|
||||
*/
|
||||
private fun getMediaButtons(
|
||||
playerCommands: Player.Commands,
|
||||
customLayout: ImmutableList<CommandButton>,
|
||||
showPauseButton: Boolean
|
||||
): ImmutableList<CommandButton> {
|
||||
val commandButtons = ImmutableList.Builder<CommandButton>()
|
||||
if (playerCommands.containsAny(Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
|
||||
val commandButtonExtras = Bundle()
|
||||
commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET)
|
||||
commandButtons.add(
|
||||
CommandButton.Builder()
|
||||
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.setIconResId(R.drawable.exo_icon_rewind)
|
||||
.setDisplayName(
|
||||
context.getString(R.string.media3_controls_seek_to_previous_description)
|
||||
)
|
||||
.setExtras(commandButtonExtras)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
if (playerCommands.contains(Player.COMMAND_PLAY_PAUSE)) {
|
||||
val commandButtonExtras = Bundle()
|
||||
commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET)
|
||||
commandButtons.add(
|
||||
CommandButton.Builder()
|
||||
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
|
||||
.setIconResId(
|
||||
if (showPauseButton) R.drawable.exo_notification_pause else R.drawable.exo_notification_play
|
||||
)
|
||||
.setExtras(commandButtonExtras)
|
||||
.setDisplayName(
|
||||
if (showPauseButton) context.getString(R.string.media3_controls_pause_description) else context.getString(R.string.media3_controls_play_description)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
if (playerCommands.containsAny(Player.COMMAND_STOP)) {
|
||||
val commandButtonExtras = Bundle()
|
||||
commandButtons.add(
|
||||
CommandButton.Builder()
|
||||
.setPlayerCommand(Player.COMMAND_STOP)
|
||||
.setIconResId(R.drawable.exo_notification_stop)
|
||||
.setExtras(commandButtonExtras)
|
||||
.setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
for (i in customLayout.indices) {
|
||||
val button = customLayout[i]
|
||||
if (button.sessionCommand != null &&
|
||||
button.sessionCommand!!.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
|
||||
) {
|
||||
commandButtons.add(button)
|
||||
}
|
||||
}
|
||||
return commandButtons.build()
|
||||
}
|
||||
|
||||
private fun createCurrentContentIntent(extras: Bundle): PendingIntent? {
|
||||
val serializedRecipientId = extras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID) ?: return null
|
||||
val recipientId = RecipientId.from(serializedRecipientId)
|
||||
val startingPosition = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION)
|
||||
val threadId = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID)
|
||||
|
||||
val conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId)
|
||||
.withStartingPosition(startingPosition.toInt())
|
||||
.build()
|
||||
|
||||
conversationActivity.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
conversationActivity,
|
||||
cancelCurrent()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This will either fetch a cached bitmap and add it to the builder immediately,
|
||||
* OR it will set a callback to update the notification once the bitmap is fetched by [AvatarUtil]
|
||||
*/
|
||||
private fun addLargeIcon(builder: NotificationCompat.Builder, extras: Bundle?, callback: MediaNotification.Provider.Callback) {
|
||||
if (extras == null || !SignalStore.settings().messageNotificationsPrivacy.isDisplayContact) {
|
||||
cachedBitmap = null
|
||||
cachedRecipientId = null
|
||||
return
|
||||
}
|
||||
|
||||
val serializedRecipientId: String = extras.getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID) ?: return
|
||||
|
||||
val currentRecipientId = RecipientId.from(serializedRecipientId)
|
||||
|
||||
if (currentRecipientId == cachedRecipientId && cachedBitmap != null) {
|
||||
builder.setLargeIcon(cachedBitmap)
|
||||
} else {
|
||||
cachedRecipientId = currentRecipientId
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
try {
|
||||
cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId!!))
|
||||
builder.setLargeIcon(cachedBitmap)
|
||||
callback.onNotificationChanged(MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build()))
|
||||
} catch (e: Exception) {
|
||||
cachedBitmap = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We do not currently support any custom commands in the notification area.
|
||||
*/
|
||||
override fun handleCustomCommand(session: MediaSession, action: String, extras: Bundle): Boolean {
|
||||
throw UnsupportedOperationException("Custom command handler for Notification is unused.")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOW_PLAYING_NOTIFICATION_ID = 32221
|
||||
private const val TAG = "VoiceNoteMediaNotificationProvider"
|
||||
}
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.support.v4.media.session.MediaControllerCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
|
||||
|
||||
import org.signal.core.util.PendingIntentFlags;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
class VoiceNoteNotificationManager {
|
||||
|
||||
private static final short NOW_PLAYING_NOTIFICATION_ID = 32221;
|
||||
|
||||
private final Context context;
|
||||
private final MediaControllerCompat controller;
|
||||
private final PlayerNotificationManager notificationManager;
|
||||
|
||||
VoiceNoteNotificationManager(@NonNull Context context,
|
||||
@NonNull MediaSessionCompat.Token token,
|
||||
@NonNull PlayerNotificationManager.NotificationListener listener)
|
||||
{
|
||||
this.context = context;
|
||||
controller = new MediaControllerCompat(context, token);
|
||||
notificationManager = new PlayerNotificationManager.Builder(context, NOW_PLAYING_NOTIFICATION_ID, NotificationChannels.getInstance().VOICE_NOTES)
|
||||
.setChannelNameResourceId(R.string.NotificationChannel_voice_notes)
|
||||
.setMediaDescriptionAdapter(new DescriptionAdapter())
|
||||
.setNotificationListener(listener)
|
||||
.build();
|
||||
|
||||
notificationManager.setMediaSessionToken(token);
|
||||
notificationManager.setSmallIcon(R.drawable.ic_notification);
|
||||
notificationManager.setColorized(true);
|
||||
notificationManager.setUseFastForwardAction(false);
|
||||
notificationManager.setUseRewindAction(false);
|
||||
notificationManager.setUseStopAction(true);
|
||||
}
|
||||
|
||||
public void hideNotification() {
|
||||
notificationManager.setPlayer(null);
|
||||
}
|
||||
|
||||
public void showNotification(@NonNull Player player) {
|
||||
notificationManager.setPlayer(player);
|
||||
}
|
||||
|
||||
private final class DescriptionAdapter implements PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
|
||||
private RecipientId cachedRecipientId;
|
||||
private Bitmap cachedBitmap;
|
||||
|
||||
@Override
|
||||
public String getCurrentContentTitle(Player player) {
|
||||
if (hasMetadata()) {
|
||||
return Objects.toString(controller.getMetadata().getDescription().getTitle(), null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable PendingIntent createCurrentContentIntent(Player player) {
|
||||
if (!hasMetadata()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID);
|
||||
if (serializedRecipientId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
RecipientId recipientId = RecipientId.from(serializedRecipientId);
|
||||
int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION);
|
||||
long threadId = controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID);
|
||||
|
||||
int color = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR);
|
||||
|
||||
if (color == 0) {
|
||||
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor();
|
||||
}
|
||||
|
||||
notificationManager.setColor(color);
|
||||
|
||||
Intent conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId)
|
||||
.withStartingPosition(startingPosition)
|
||||
.build();
|
||||
|
||||
conversationActivity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
return PendingIntent.getActivity(context,
|
||||
0,
|
||||
conversationActivity,
|
||||
PendingIntentFlags.cancelCurrent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentContentText(Player player) {
|
||||
if (hasMetadata()) {
|
||||
return Objects.toString(controller.getMetadata().getDescription().getSubtitle(), null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Bitmap getCurrentLargeIcon(Player player, PlayerNotificationManager.BitmapCallback callback) {
|
||||
if (!hasMetadata() || !SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) {
|
||||
cachedBitmap = null;
|
||||
cachedRecipientId = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID);
|
||||
if (serializedRecipientId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
RecipientId currentRecipientId = RecipientId.from(serializedRecipientId);
|
||||
|
||||
if (Objects.equals(currentRecipientId, cachedRecipientId) && cachedBitmap != null) {
|
||||
return cachedBitmap;
|
||||
} else {
|
||||
cachedRecipientId = currentRecipientId;
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
try {
|
||||
cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId));
|
||||
callback.onBitmap(cachedBitmap);
|
||||
} catch (Exception e) {
|
||||
cachedBitmap = null;
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasMetadata() {
|
||||
return controller.getMetadata() != null && controller.getMetadata().getDescription() != null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.PlaybackParameters
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class VoiceNotePlaybackController(
|
||||
private val player: ExoPlayer,
|
||||
private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters
|
||||
) : MediaSessionConnector.CommandReceiver {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VoiceNoteMediaController::class.java)
|
||||
}
|
||||
|
||||
override fun onCommand(p: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
|
||||
Log.d(TAG, "[onCommand] Received player command $command (extras? ${extras != null})")
|
||||
|
||||
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)
|
||||
return true
|
||||
} else if (command == VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) {
|
||||
val newStreamType: Int = extras?.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) ?: AudioManager.STREAM_MUSIC
|
||||
|
||||
val currentStreamType = Util.getStreamTypeForAudioUsage(player.audioAttributes.usage)
|
||||
if (newStreamType != currentStreamType) {
|
||||
val attributes = when (newStreamType) {
|
||||
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
|
||||
AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
player.playWhenReady = false
|
||||
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
|
||||
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,298 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.ResultReceiver;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.MediaMetadata;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI
|
||||
*/
|
||||
final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackPreparer {
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNotePlaybackPreparer.class);
|
||||
private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
private static final long LIMIT = 5;
|
||||
|
||||
private final Context context;
|
||||
private final Player player;
|
||||
private final VoiceNotePlaybackParameters voiceNotePlaybackParameters;
|
||||
|
||||
private boolean canLoadMore;
|
||||
private Uri latestUri = Uri.EMPTY;
|
||||
|
||||
VoiceNotePlaybackPreparer(@NonNull Context context,
|
||||
@NonNull Player player,
|
||||
@NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters)
|
||||
{
|
||||
this.context = context;
|
||||
this.player = player;
|
||||
this.voiceNotePlaybackParameters = voiceNotePlaybackParameters;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSupportedPrepareActions() {
|
||||
return PlaybackStateCompat.ACTION_PLAY_FROM_URI;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepare(boolean playWhenReady) {
|
||||
Log.w(TAG, "Requested playback from IDLE state. Ignoring.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareFromMediaId(@NonNull String mediaId, boolean playWhenReady, @Nullable Bundle extras) {
|
||||
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromMediaId");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareFromSearch(@NonNull String query, boolean playWhenReady, @Nullable Bundle extras) {
|
||||
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromSearch");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareFromUri(@NonNull Uri uri, boolean playWhenReady, @Nullable Bundle extras) {
|
||||
Log.d(TAG, "onPrepareFromUri: " + uri);
|
||||
if (extras == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
|
||||
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
|
||||
double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0);
|
||||
boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false);
|
||||
|
||||
canLoadMore = false;
|
||||
latestUri = uri;
|
||||
|
||||
SimpleTask.run(EXECUTOR,
|
||||
() -> {
|
||||
if (singlePlayback) {
|
||||
if (messageId != -1) {
|
||||
return loadMediaItemsForSinglePlayback(messageId);
|
||||
} else {
|
||||
return loadMediaItemsForDraftPlayback(threadId, uri);
|
||||
}
|
||||
} else {
|
||||
return loadMediaItemsForConsecutivePlayback(messageId);
|
||||
}
|
||||
},
|
||||
mediaItems -> {
|
||||
player.clearMediaItems();
|
||||
|
||||
if (Util.hasItems(mediaItems) && Objects.equals(latestUri, uri)) {
|
||||
applyDescriptionsToQueue(mediaItems);
|
||||
|
||||
int window = Math.max(0, indexOfPlayerMediaItemByUri(uri));
|
||||
|
||||
player.addListener(new Player.Listener() {
|
||||
@Override
|
||||
public void onTimelineChanged(@NonNull Timeline timeline, 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
player.prepare();
|
||||
canLoadMore = !singlePlayback;
|
||||
} else if (Objects.equals(latestUri, uri)) {
|
||||
Log.w(TAG, "Requested playback but no voice notes could be found.");
|
||||
ThreadUtil.postToMain(() -> {
|
||||
Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private void applyDescriptionsToQueue(@NonNull List<MediaItem> mediaItems) {
|
||||
for (MediaItem mediaItem : mediaItems) {
|
||||
final MediaItem.LocalConfiguration playbackProperties = mediaItem.playbackProperties;
|
||||
if (playbackProperties == null) {
|
||||
continue;
|
||||
}
|
||||
int holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri);
|
||||
MediaItem next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem);
|
||||
int currentIndex = player.getCurrentWindowIndex();
|
||||
|
||||
if (holderIndex != -1) {
|
||||
if (currentIndex != holderIndex) {
|
||||
player.removeMediaItem(holderIndex);
|
||||
player.addMediaItem(holderIndex, mediaItem);
|
||||
}
|
||||
|
||||
if (currentIndex != holderIndex + 1) {
|
||||
if (player.getMediaItemCount() > 1) {
|
||||
player.removeMediaItem(holderIndex + 1);
|
||||
}
|
||||
|
||||
player.addMediaItem(holderIndex + 1, next);
|
||||
}
|
||||
} else {
|
||||
int insertLocation = indexAfter(mediaItem);
|
||||
|
||||
player.addMediaItem(insertLocation, next);
|
||||
player.addMediaItem(insertLocation, mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
int itemsCount = player.getMediaItemCount();
|
||||
if (itemsCount > 0) {
|
||||
int lastIndex = itemsCount - 1;
|
||||
MediaItem last = player.getMediaItemAt(lastIndex);
|
||||
|
||||
if (last.playbackProperties != null &&
|
||||
Objects.equals(last.playbackProperties.uri, VoiceNoteMediaItemFactory.NEXT_URI))
|
||||
{
|
||||
player.removeMediaItem(lastIndex);
|
||||
|
||||
if (player.getMediaItemCount() > 1) {
|
||||
MediaItem end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last);
|
||||
|
||||
player.addMediaItem(lastIndex, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int indexOfPlayerMediaItemByUri(@NonNull Uri uri) {
|
||||
for (int i = 0; i < player.getMediaItemCount(); i++) {
|
||||
final MediaItem.LocalConfiguration playbackProperties = player.getMediaItemAt(i).playbackProperties;
|
||||
if (playbackProperties != null && playbackProperties.uri.equals(uri)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int indexAfter(@NonNull MediaItem target) {
|
||||
int size = player.getMediaItemCount();
|
||||
long targetMessageId = target.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
|
||||
for (int i = 0; i < size; i++) {
|
||||
MediaMetadata mediaMetadata = player.getMediaItemAt(i).mediaMetadata;
|
||||
long messageId = mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
|
||||
|
||||
if (messageId > targetMessageId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
public void loadMoreVoiceNotes() {
|
||||
if (!canLoadMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
MediaItem currentMediaItem = player.getCurrentMediaItem();
|
||||
if (currentMediaItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long messageId = currentMediaItem.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
|
||||
|
||||
SimpleTask.run(EXECUTOR,
|
||||
() -> loadMediaItemsForConsecutivePlayback(messageId),
|
||||
mediaItems -> {
|
||||
if (Util.hasItems(mediaItems) && canLoadMore) {
|
||||
applyDescriptionsToQueue(mediaItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull List<MediaItem> loadMediaItemsForSinglePlayback(long messageId) {
|
||||
try {
|
||||
MessageRecord messageRecord = SignalDatabase.messages()
|
||||
.getMessageRecord(messageId);
|
||||
|
||||
if (!MessageRecordUtil.hasAudio(messageRecord)) {
|
||||
Log.w(TAG, "Message does not contain audio.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
MediaItem mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord);
|
||||
if (mediaItem == null) {
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
return Collections.singletonList(mediaItem);
|
||||
}
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull List<MediaItem> loadMediaItemsForDraftPlayback(long threadId, @NonNull Uri draftUri) {
|
||||
return Collections
|
||||
.singletonList(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<MediaItem> loadMediaItemsForConsecutivePlayback(long messageId) {
|
||||
try {
|
||||
List<MessageRecord> recordsAfter = SignalDatabase.messages().getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
|
||||
|
||||
return buildFilteredMessageRecordList(recordsAfter).stream()
|
||||
.map(record -> VoiceNoteMediaItemFactory
|
||||
.buildMediaItem(context, record))
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull List<MessageRecord> buildFilteredMessageRecordList(@NonNull List<MessageRecord> recordsAfter) {
|
||||
return Stream.of(recordsAfter)
|
||||
.takeWhile(MessageRecordUtil::hasAudio)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public boolean onCommand(@NonNull Player player,
|
||||
@NonNull String command,
|
||||
@Nullable Bundle extras,
|
||||
@Nullable ResultReceiver cb)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -1,31 +1,33 @@
|
|||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Process;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
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.media.MediaBrowserServiceCompat;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media3.common.AudioAttributes;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.PlaybackParameters;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaController;
|
||||
import androidx.media3.session.MediaSession;
|
||||
import androidx.media3.session.MediaSessionService;
|
||||
import androidx.media3.session.SessionToken;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
@ -33,105 +35,69 @@ import org.thoughtcrime.securesms.database.MessageTable;
|
|||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.ForegroundServiceUtil;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
|
||||
import org.thoughtcrime.securesms.jobs.UnableToStartException;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Android Service responsible for playback of voice notes.
|
||||
*/
|
||||
public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public class VoiceNotePlaybackService extends MediaSessionService {
|
||||
|
||||
public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed";
|
||||
public static final String ACTION_SET_AUDIO_STREAM = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.set_audio_stream";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNotePlaybackService.class);
|
||||
private static final String SESSION_ID = "VoiceNotePlayback";
|
||||
private static final String EMPTY_ROOT_ID = "empty-root-id";
|
||||
private static final int LOAD_MORE_THRESHOLD = 2;
|
||||
|
||||
private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY |
|
||||
PlaybackStateCompat.ACTION_PAUSE |
|
||||
PlaybackStateCompat.ACTION_SEEK_TO |
|
||||
PlaybackStateCompat.ACTION_STOP |
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE;
|
||||
|
||||
private MediaSessionCompat mediaSession;
|
||||
private MediaSessionConnector mediaSessionConnector;
|
||||
private VoiceNotePlayer player;
|
||||
private BecomingNoisyReceiver becomingNoisyReceiver;
|
||||
private KeyClearedReceiver keyClearedReceiver;
|
||||
private VoiceNoteNotificationManager voiceNoteNotificationManager;
|
||||
private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer;
|
||||
private boolean isForegroundService;
|
||||
private VoiceNotePlaybackParameters voiceNotePlaybackParameters;
|
||||
private MediaSession mediaSession;
|
||||
private VoiceNotePlayer player;
|
||||
private KeyClearedReceiver keyClearedReceiver;
|
||||
private VoiceNotePlayerCallback voiceNotePlayerCallback;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
mediaSession = new MediaSessionCompat(this, TAG);
|
||||
voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession);
|
||||
mediaSessionConnector = new MediaSessionConnector(mediaSession);
|
||||
becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken());
|
||||
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getSessionToken());
|
||||
player = new VoiceNotePlayer(this);
|
||||
voiceNoteNotificationManager = new VoiceNoteNotificationManager(this,
|
||||
mediaSession.getSessionToken(),
|
||||
new VoiceNoteNotificationManagerListener());
|
||||
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, voiceNotePlaybackParameters);
|
||||
|
||||
player = new VoiceNotePlayer(this);
|
||||
player.addListener(new VoiceNotePlayerEventListener());
|
||||
|
||||
mediaSessionConnector.setPlayer(player);
|
||||
mediaSessionConnector.setEnabledPlaybackActions(SUPPORTED_ACTIONS);
|
||||
mediaSessionConnector.setPlaybackPreparer(voiceNotePlaybackPreparer);
|
||||
mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession));
|
||||
voiceNotePlayerCallback = new VoiceNotePlayerCallback(this, player);
|
||||
mediaSession = new MediaSession.Builder(this, player).setCallback(voiceNotePlayerCallback).setId(SESSION_ID).build();
|
||||
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getToken());
|
||||
|
||||
VoiceNotePlaybackController voiceNotePlaybackController = new VoiceNotePlaybackController(player.getInternalPlayer(), voiceNotePlaybackParameters);
|
||||
mediaSessionConnector.registerCustomCommandReceiver(voiceNotePlaybackController);
|
||||
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
|
||||
mediaSession.setActive(true);
|
||||
keyClearedReceiver.register();
|
||||
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
|
||||
setListener(new MediaSessionServiceListener());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskRemoved(Intent rootIntent) {
|
||||
super.onTaskRemoved(rootIntent);
|
||||
|
||||
player.stop();
|
||||
player.clearMediaItems();
|
||||
mediaSession.getPlayer().stop();
|
||||
mediaSession.getPlayer().clearMediaItems();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
mediaSession.setActive(false);
|
||||
mediaSession.release();
|
||||
becomingNoisyReceiver.unregister();
|
||||
keyClearedReceiver.unregister();
|
||||
player.release();
|
||||
mediaSession.release();
|
||||
mediaSession = null;
|
||||
clearListener();
|
||||
mediaSession = null;
|
||||
super.onDestroy();
|
||||
keyClearedReceiver.unregister();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
|
||||
if (clientUid == Process.myUid()) {
|
||||
return new BrowserRoot(EMPTY_ROOT_ID, null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
result.sendResult(Collections.emptyList());
|
||||
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
|
||||
return mediaSession;
|
||||
}
|
||||
|
||||
private class VoiceNotePlayerEventListener implements Player.Listener {
|
||||
|
@ -150,20 +116,14 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
|||
switch (playbackState) {
|
||||
case Player.STATE_BUFFERING:
|
||||
case Player.STATE_READY:
|
||||
voiceNoteNotificationManager.showNotification(player);
|
||||
|
||||
if (!playWhenReady) {
|
||||
stopForeground(false);
|
||||
isForegroundService = false;
|
||||
becomingNoisyReceiver.unregister();
|
||||
} else {
|
||||
sendViewedReceiptForCurrentWindowIndex();
|
||||
becomingNoisyReceiver.register();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
becomingNoisyReceiver.unregister();
|
||||
voiceNoteNotificationManager.hideNotification();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,7 +158,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
|||
currentWindowIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
|
||||
|
||||
if (isWithinThreshold && currentWindowIndex % 2 == 0) {
|
||||
voiceNotePlaybackPreparer.loadMoreVoiceNotes();
|
||||
voiceNotePlayerCallback.loadMoreVoiceNotes();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -217,13 +177,12 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
|||
}
|
||||
|
||||
Log.i(TAG, "onAudioAttributesChanged: Setting audio stream to " + stream);
|
||||
mediaSession.setPlaybackToLocal(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
|
||||
if (isAudioMessage(currentWindowIndex)) {
|
||||
return voiceNotePlaybackParameters.getParameters();
|
||||
return player.getPlaybackParameters();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -271,49 +230,48 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
|||
}
|
||||
}
|
||||
|
||||
private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener {
|
||||
|
||||
@Override
|
||||
public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) {
|
||||
if (ongoing && !isForegroundService) {
|
||||
try {
|
||||
ForegroundServiceUtil.start(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
|
||||
startForeground(notificationId, notification);
|
||||
isForegroundService = true;
|
||||
} catch (UnableToStartException e) {
|
||||
Log.e(TAG, "Unable to start foreground service!", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotificationCancelled(int notificationId, boolean dismissedByUser) {
|
||||
stopForeground(true);
|
||||
isForegroundService = false;
|
||||
stopSelf();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
|
||||
* This registers itself as a receiver on the [Context] as soon as it can.
|
||||
*/
|
||||
private static class KeyClearedReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = Log.tag(KeyClearedReceiver.class);
|
||||
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
|
||||
|
||||
private final Context context;
|
||||
private final MediaControllerCompat controller;
|
||||
private final Context context;
|
||||
private final ListenableFuture<MediaController> controllerFuture;
|
||||
private MediaController controller;
|
||||
|
||||
private boolean registered;
|
||||
|
||||
private KeyClearedReceiver(@NonNull Context context, @NonNull MediaSessionCompat.Token token) {
|
||||
this.context = context;
|
||||
this.controller = new MediaControllerCompat(context, token);
|
||||
private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) {
|
||||
this.context = context;
|
||||
Log.d(TAG, "Creating media controller…");
|
||||
controllerFuture = new MediaController.Builder(context, token).buildAsync();
|
||||
Futures.addCallback(controllerFuture, new FutureCallback<>() {
|
||||
@Override public void onSuccess(@Nullable MediaController result) {
|
||||
Log.e(TAG, "Successfully created media controller.");
|
||||
controller = result;
|
||||
register();
|
||||
}
|
||||
|
||||
@Override public void onFailure(@NonNull Throwable t) {
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context));
|
||||
}
|
||||
|
||||
void register() {
|
||||
if (controller == null) {
|
||||
Log.e(TAG, "Failed to register KeyClearedReceiver because MediaController was null.");
|
||||
}
|
||||
if (!registered) {
|
||||
context.registerReceiver(this, KEY_CLEARED_FILTER);
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(this, KEY_CLEARED_FILTER, RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
context.registerReceiver(this, KEY_CLEARED_FILTER);
|
||||
}
|
||||
registered = true;
|
||||
Log.e(TAG, "Successfully registered.");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -322,48 +280,24 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
|||
context.unregisterReceiver(this);
|
||||
registered = false;
|
||||
}
|
||||
MediaController.releaseFuture(controllerFuture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
controller.getTransportControls().stop();
|
||||
if (controller == null) {
|
||||
Log.e(TAG, "Received broadcast but could not stop playback because MediaController was null.");
|
||||
} else {
|
||||
Log.i(TAG, "Received broadcast, stopping playback.");
|
||||
controller.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to pause playback when things become noisy.
|
||||
*/
|
||||
private static class BecomingNoisyReceiver extends BroadcastReceiver {
|
||||
private static final IntentFilter NOISY_INTENT_FILTER = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
|
||||
|
||||
private final Context context;
|
||||
private final MediaControllerCompat controller;
|
||||
|
||||
private boolean registered;
|
||||
|
||||
private BecomingNoisyReceiver(Context context, MediaSessionCompat.Token token) {
|
||||
this.context = context;
|
||||
this.controller = new MediaControllerCompat(context, token);
|
||||
}
|
||||
|
||||
void register() {
|
||||
if (!registered) {
|
||||
context.registerReceiver(this, NOISY_INTENT_FILTER);
|
||||
registered = true;
|
||||
}
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
if (registered) {
|
||||
context.unregisterReceiver(this);
|
||||
registered = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void onReceive(Context context, @NonNull Intent intent) {
|
||||
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
|
||||
controller.getTransportControls().pause();
|
||||
}
|
||||
private static class MediaSessionServiceListener implements Listener {
|
||||
@Override
|
||||
public void onForegroundServiceStartNotAllowedException() {
|
||||
Log.e(TAG, "Could not start VoiceNotePlaybackService, encountered a ForegroundServiceStartNotAllowedException.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,29 +1,47 @@
|
|||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.DefaultLoadControl
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.ForwardingPlayer
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.audio.AudioSink
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
|
||||
|
||||
/**
|
||||
* A lightweight wrapper around ExoPlayer that compartmentalizes some logic and adds a few functions, most importantly the seek behavior.
|
||||
*
|
||||
* @param context
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class VoiceNotePlayer @JvmOverloads constructor(
|
||||
context: Context,
|
||||
val internalPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||||
private val internalPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||||
.setRenderersFactory(WorkaroundRenderersFactory(context))
|
||||
.setMediaSourceFactory(SignalMediaSourceFactory(context))
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)
|
||||
.build()
|
||||
).build().apply {
|
||||
setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true)
|
||||
}
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true).build()
|
||||
) : ForwardingPlayer(internalPlayer) {
|
||||
|
||||
init {
|
||||
setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required to expose this because this is unique to [ExoPlayer], not the generic [androidx.media3.common.Player] interface.
|
||||
*/
|
||||
fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {
|
||||
internalPlayer.setAudioAttributes(audioAttributes, handleAudioFocus)
|
||||
}
|
||||
|
||||
override fun seekTo(windowIndex: Int, positionMs: Long) {
|
||||
super.seekTo(windowIndex, positionMs)
|
||||
|
||||
|
@ -46,6 +64,7 @@ class VoiceNotePlayer @JvmOverloads constructor(
|
|||
/**
|
||||
* @see RetryableInitAudioSink
|
||||
*/
|
||||
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class WorkaroundRenderersFactory(val context: Context) : DefaultRenderersFactory(context) {
|
||||
override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean): AudioSink? {
|
||||
return RetryableInitAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams, enableOffload)
|
||||
|
|
|
@ -0,0 +1,322 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.LocalConfiguration
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionCommands
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.hasAudio
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.stream.Collectors
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* See [VoiceNotePlaybackService].
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer) : MediaSession.Callback {
|
||||
companion object {
|
||||
private val SUPPORTED_ACTIONS = Player.Commands.Builder()
|
||||
.addAll(
|
||||
Player.COMMAND_PLAY_PAUSE,
|
||||
Player.COMMAND_PREPARE,
|
||||
Player.COMMAND_STOP,
|
||||
Player.COMMAND_SEEK_TO_DEFAULT_POSITION,
|
||||
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_BACK,
|
||||
Player.COMMAND_SEEK_FORWARD,
|
||||
Player.COMMAND_SET_SPEED_AND_PITCH,
|
||||
Player.COMMAND_SET_REPEAT_MODE,
|
||||
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||
Player.COMMAND_GET_TIMELINE,
|
||||
Player.COMMAND_GET_METADATA,
|
||||
Player.COMMAND_SET_PLAYLIST_METADATA,
|
||||
Player.COMMAND_SET_MEDIA_ITEM,
|
||||
Player.COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
Player.COMMAND_GET_AUDIO_ATTRIBUTES,
|
||||
Player.COMMAND_GET_TEXT,
|
||||
Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS,
|
||||
Player.COMMAND_RELEASE
|
||||
)
|
||||
.build()
|
||||
|
||||
private val CUSTOM_COMMANDS = SessionCommands.Builder()
|
||||
.add(SessionCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, Bundle.EMPTY))
|
||||
.add(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY))
|
||||
.build()
|
||||
private const val DEFAULT_PLAYBACK_SPEED = 1f
|
||||
private const val LIMIT: Long = 5
|
||||
}
|
||||
|
||||
private val TAG = Log.tag(VoiceNotePlayerCallback::class.java)
|
||||
private val EXECUTOR: Executor = Executors.newSingleThreadExecutor()
|
||||
private val customLayout: List<CommandButton> = mutableListOf<CommandButton>().apply {
|
||||
add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_PLAY_PAUSE).build())
|
||||
add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_STOP).build())
|
||||
}
|
||||
private var canLoadMore = false
|
||||
private var latestUri = Uri.EMPTY
|
||||
|
||||
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
|
||||
session.setAvailableCommands(controller, CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
|
||||
return super.onConnect(session, controller)
|
||||
}
|
||||
|
||||
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
|
||||
if (customLayout.isNotEmpty() && controller.controllerVersion != 0) {
|
||||
session.setCustomLayout(controller, customLayout)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList<MediaItem>): ListenableFuture<MutableList<MediaItem>> {
|
||||
mediaItems.forEach {
|
||||
val uri = it.localConfiguration?.uri
|
||||
if (uri != null) {
|
||||
val extras = it.requestMetadata.extras
|
||||
onPrepareFromUri(uri, extras)
|
||||
} else {
|
||||
throw UnsupportedOperationException("VoiceNotePlayerCallback does not support onPrepareFromMediaId/onPrepareFromSearch")
|
||||
}
|
||||
}
|
||||
return super.onAddMediaItems(mediaSession, controller, mediaItems)
|
||||
}
|
||||
|
||||
private fun onPrepareFromUri(uri: Uri, extras: Bundle?) {
|
||||
Log.d(TAG, "onPrepareFromUri: $uri")
|
||||
if (extras == null) {
|
||||
return
|
||||
}
|
||||
val messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID)
|
||||
val threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID)
|
||||
val progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0.0)
|
||||
val singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false)
|
||||
canLoadMore = false
|
||||
latestUri = uri
|
||||
SimpleTask.run(
|
||||
EXECUTOR,
|
||||
{
|
||||
if (singlePlayback) {
|
||||
if (messageId != -1L) {
|
||||
return@run loadMediaItemsForSinglePlayback(messageId)
|
||||
} else {
|
||||
return@run loadMediaItemsForDraftPlayback(threadId, uri)
|
||||
}
|
||||
} else {
|
||||
return@run loadMediaItemsForConsecutivePlayback(messageId)
|
||||
}
|
||||
}
|
||||
) { mediaItems: List<MediaItem> ->
|
||||
player.clearMediaItems()
|
||||
if (mediaItems.isNotEmpty() && latestUri == uri) {
|
||||
applyDescriptionsToQueue(mediaItems)
|
||||
val window = max(0, indexOfPlayerMediaItemByUri(uri))
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
if (timeline.windowCount >= window) {
|
||||
player.playWhenReady = false
|
||||
player.playbackParameters = PlaybackParameters(DEFAULT_PLAYBACK_SPEED)
|
||||
player.seekTo(window, (player.duration * progress).toLong())
|
||||
player.playWhenReady = true
|
||||
player.removeListener(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
player.prepare()
|
||||
canLoadMore = !singlePlayback
|
||||
} else if (latestUri == uri) {
|
||||
Log.w(TAG, "Requested playback but no voice notes could be found.")
|
||||
ThreadUtil.postToMain {
|
||||
Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture<SessionResult> {
|
||||
return when (customCommand.customAction) {
|
||||
VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED -> incrementPlaybackSpeed(args)
|
||||
VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM -> setAudioStream(args)
|
||||
else -> super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
}
|
||||
|
||||
private fun incrementPlaybackSpeed(extras: Bundle): ListenableFuture<SessionResult> {
|
||||
val speed = extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f)
|
||||
player.playbackParameters = PlaybackParameters(speed)
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
private fun setAudioStream(extras: Bundle): ListenableFuture<SessionResult> {
|
||||
val newStreamType: Int = extras.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC)
|
||||
|
||||
val currentStreamType = androidx.media3.common.util.Util.getStreamTypeForAudioUsage(player.audioAttributes.usage)
|
||||
if (newStreamType != currentStreamType) {
|
||||
val attributes = when (newStreamType) {
|
||||
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
|
||||
AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
player.playWhenReady = false
|
||||
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
|
||||
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun applyDescriptionsToQueue(mediaItems: List<MediaItem>) {
|
||||
for (mediaItem in mediaItems) {
|
||||
val playbackProperties = mediaItem.localConfiguration ?: continue
|
||||
val holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri)
|
||||
val next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem)
|
||||
val currentIndex: Int = player.currentMediaItemIndex
|
||||
if (holderIndex != -1) {
|
||||
if (currentIndex != holderIndex) {
|
||||
player.removeMediaItem(holderIndex)
|
||||
player.addMediaItem(holderIndex, mediaItem)
|
||||
}
|
||||
if (currentIndex != holderIndex + 1) {
|
||||
if (player.mediaItemCount > 1) {
|
||||
player.removeMediaItem(holderIndex + 1)
|
||||
}
|
||||
player.addMediaItem(holderIndex + 1, next)
|
||||
}
|
||||
} else {
|
||||
val insertLocation = indexAfter(mediaItem)
|
||||
player.addMediaItem(insertLocation, next)
|
||||
player.addMediaItem(insertLocation, mediaItem)
|
||||
}
|
||||
}
|
||||
val itemsCount: Int = player.mediaItemCount
|
||||
if (itemsCount > 0) {
|
||||
val lastIndex = itemsCount - 1
|
||||
val last: MediaItem = player.getMediaItemAt(lastIndex)
|
||||
if (last.localConfiguration?.uri == VoiceNoteMediaItemFactory.NEXT_URI) {
|
||||
player.removeMediaItem(lastIndex)
|
||||
if (player.mediaItemCount > 1) {
|
||||
val end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last)
|
||||
player.addMediaItem(lastIndex, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun indexOfPlayerMediaItemByUri(uri: Uri): Int {
|
||||
for (i in 0 until player.mediaItemCount) {
|
||||
val playbackProperties: LocalConfiguration? = player.getMediaItemAt(i).playbackProperties
|
||||
if (playbackProperties?.uri == uri) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun indexAfter(target: MediaItem): Int {
|
||||
val size: Int = player.mediaItemCount
|
||||
val targetMessageId = target.mediaMetadata.extras?.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID) ?: 0L
|
||||
for (i in 0 until size) {
|
||||
val mediaMetadata: MediaMetadata = player.getMediaItemAt(i).mediaMetadata
|
||||
val messageId = mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
|
||||
if (messageId > targetMessageId) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
fun loadMoreVoiceNotes() {
|
||||
if (!canLoadMore) {
|
||||
return
|
||||
}
|
||||
val currentMediaItem: MediaItem = player.currentMediaItem ?: return
|
||||
val messageId = currentMediaItem.mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
|
||||
SimpleTask.run(
|
||||
EXECUTOR,
|
||||
{ loadMediaItemsForConsecutivePlayback(messageId) }
|
||||
) { mediaItems: List<MediaItem> ->
|
||||
if (Util.hasItems(mediaItems) && canLoadMore) {
|
||||
applyDescriptionsToQueue(mediaItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMediaItemsForSinglePlayback(messageId: Long): List<MediaItem> {
|
||||
return try {
|
||||
val messageRecord = messages
|
||||
.getMessageRecord(messageId)
|
||||
if (!messageRecord.hasAudio()) {
|
||||
Log.w(TAG, "Message does not contain audio.")
|
||||
return emptyList<MediaItem>()
|
||||
}
|
||||
val mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord)
|
||||
mediaItem?.let { listOf(it) } ?: emptyList()
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Could not find message.", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMediaItemsForDraftPlayback(threadId: Long, draftUri: Uri): List<MediaItem> {
|
||||
return listOf<MediaItem>(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun loadMediaItemsForConsecutivePlayback(messageId: Long): List<MediaItem> {
|
||||
return try {
|
||||
val recordsAfter = messages.getMessagesAfterVoiceNoteInclusive(messageId, Companion.LIMIT)
|
||||
recordsAfter.filter { it.hasAudio() }.stream()
|
||||
.map<MediaItem?> { record: MessageRecord? ->
|
||||
VoiceNoteMediaItemFactory
|
||||
.buildMediaItem(context, record!!)
|
||||
}
|
||||
.filter { obj: MediaItem? -> Objects.nonNull(obj) }
|
||||
.collect(Collectors.toList())
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Could not find message.", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,12 +7,13 @@ import android.hardware.SensorManager
|
|||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionCommand
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -25,7 +26,7 @@ private const val PROXIMITY_THRESHOLD = 5f
|
|||
*/
|
||||
class VoiceNoteProximityWakeLockManager(
|
||||
private val activity: FragmentActivity,
|
||||
private val mediaController: MediaControllerCompat
|
||||
private val mediaController: MediaController
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
|
||||
|
@ -33,7 +34,7 @@ class VoiceNoteProximityWakeLockManager(
|
|||
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
|
||||
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
|
||||
|
||||
private val mediaControllerCallback = MediaControllerCallback()
|
||||
private val mediaControllerCallback = ProximityListener()
|
||||
private val hardwareSensorEventListener = HardwareSensorEventListener()
|
||||
|
||||
private var startTime: Long = -1
|
||||
|
@ -46,7 +47,7 @@ class VoiceNoteProximityWakeLockManager(
|
|||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
if (proximitySensor != null) {
|
||||
mediaController.registerCallback(mediaControllerCallback)
|
||||
mediaController.addListener(mediaControllerCallback)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -57,7 +58,7 @@ class VoiceNoteProximityWakeLockManager(
|
|||
}
|
||||
|
||||
fun unregisterCallbacksAndRelease() {
|
||||
mediaController.unregisterCallback(mediaControllerCallback)
|
||||
mediaController.addListener(mediaControllerCallback)
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
|
||||
|
@ -69,9 +70,6 @@ class VoiceNoteProximityWakeLockManager(
|
|||
|
||||
private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
|
||||
private fun isPlayerActive() = mediaController.playbackState.state == PlaybackStateCompat.STATE_BUFFERING ||
|
||||
mediaController.playbackState.state == PlaybackStateCompat.STATE_PLAYING
|
||||
|
||||
private fun cleanUpWakeLock() {
|
||||
startTime = -1L
|
||||
sensorManager.unregisterListener(hardwareSensorEventListener)
|
||||
|
@ -87,26 +85,29 @@ class VoiceNoteProximityWakeLockManager(
|
|||
private fun sendNewStreamTypeToPlayer(newStreamType: Int) {
|
||||
val params = Bundle()
|
||||
params.putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, newStreamType)
|
||||
mediaController.sendCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, params, null)
|
||||
mediaController.sendCustomCommand(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY), params)
|
||||
}
|
||||
|
||||
inner class MediaControllerCallback : MediaControllerCompat.Callback() {
|
||||
override fun onPlaybackStateChanged(state: PlaybackStateCompat) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlayerActive()) {
|
||||
if (startTime == -1L) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
|
||||
inner class ProximityListener : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (player.isPlaying) {
|
||||
if (startTime == -1L) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.")
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.")
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -116,7 +117,7 @@ class VoiceNoteProximityWakeLockManager(
|
|||
if (startTime == -1L ||
|
||||
System.currentTimeMillis() - startTime <= 500 ||
|
||||
!isActivityResumed() ||
|
||||
!isPlayerActive() ||
|
||||
!mediaController.isPlaying ||
|
||||
event.sensor.type != Sensor.TYPE_PROXIMITY
|
||||
) {
|
||||
return
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator;
|
||||
|
||||
/**
|
||||
* Navigator to help support seek forward and back.
|
||||
*/
|
||||
final class VoiceNoteQueueNavigator extends TimelineQueueNavigator {
|
||||
private static final MediaDescriptionCompat EMPTY = new MediaDescriptionCompat.Builder().build();
|
||||
|
||||
public VoiceNoteQueueNavigator(@NonNull MediaSessionCompat mediaSession) {
|
||||
super(mediaSession);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MediaDescriptionCompat getMediaDescription(@NonNull Player player, int windowIndex) {
|
||||
MediaItem mediaItem = windowIndex >= 0 && windowIndex < player.getMediaItemCount() ? player.getMediaItemAt(windowIndex) : null;
|
||||
|
||||
if (mediaItem == null || mediaItem.playbackProperties == null) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
MediaDescriptionCompat mediaDescriptionCompat = (MediaDescriptionCompat) mediaItem.playbackProperties.tag;
|
||||
if (mediaDescriptionCompat == null) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return mediaDescriptionCompat;
|
||||
}
|
||||
}
|
|
@ -37,7 +37,7 @@ import androidx.recyclerview.widget.DiffUtil;
|
|||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import androidx.media3.common.MediaItem;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagingController;
|
||||
|
|
|
@ -57,9 +57,9 @@ import androidx.annotation.Nullable;
|
|||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.common.collect.Sets;
|
||||
|
||||
|
@ -261,7 +261,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
private boolean hasWallpaper;
|
||||
private float lastYDownRelativeToThis;
|
||||
private ProjectionList colorizerProjections = new ProjectionList(3);
|
||||
private boolean isBound = false;
|
||||
private boolean isBound = false;
|
||||
|
||||
private final Runnable shrinkBubble = new Runnable() {
|
||||
@Override
|
||||
|
|
|
@ -11,8 +11,8 @@ import android.view.ViewGroup
|
|||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toOptional
|
||||
import org.thoughtcrime.securesms.BindableConversationItem
|
||||
|
|
|
@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
|||
import org.thoughtcrime.securesms.util.EarlyMessageCache;
|
||||
import org.thoughtcrime.securesms.util.FrameRateTracker;
|
||||
import org.thoughtcrime.securesms.util.IasKeyStore;
|
||||
import org.thoughtcrime.securesms.video.exo.ExoPlayerPool;
|
||||
import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache;
|
||||
import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool;
|
||||
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat;
|
||||
|
@ -607,7 +608,7 @@ public class ApplicationDependencies {
|
|||
return giphyMp4Cache;
|
||||
}
|
||||
|
||||
public static @NonNull SimpleExoPlayerPool getExoPlayerPool() {
|
||||
public static @NonNull ExoPlayerPool getExoPlayerPool() {
|
||||
if (exoPlayerPool == null) {
|
||||
synchronized (LOCK) {
|
||||
if (exoPlayerPool == null) {
|
||||
|
|
|
@ -5,7 +5,7 @@ import android.view.ViewGroup;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import androidx.media3.common.MediaItem;
|
||||
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
|
||||
|
|
|
@ -9,14 +9,17 @@ import android.widget.FrameLayout;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.ui.AspectRatioFrameLayout;
|
||||
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -30,6 +33,7 @@ import java.util.List;
|
|||
/**
|
||||
* Object which holds on to an injected video player.
|
||||
*/
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, DefaultLifecycleObserver {
|
||||
private static final String TAG = Log.tag(GiphyMp4ProjectionPlayerHolder.class);
|
||||
|
||||
|
|
|
@ -10,13 +10,15 @@ import android.widget.FrameLayout;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.ui.AspectRatioFrameLayout;
|
||||
import androidx.media3.ui.PlayerView;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -26,14 +28,15 @@ import org.thoughtcrime.securesms.util.Projection;
|
|||
/**
|
||||
* Video Player class specifically created for the GiphyMp4Fragment.
|
||||
*/
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLifecycleObserver {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(GiphyMp4VideoPlayer.class);
|
||||
|
||||
private final StyledPlayerView exoView;
|
||||
private ExoPlayer exoPlayer;
|
||||
private CornerMask cornerMask;
|
||||
private final PlayerView exoView;
|
||||
private ExoPlayer exoPlayer;
|
||||
private CornerMask cornerMask;
|
||||
|
||||
public GiphyMp4VideoPlayer(Context context) {
|
||||
this(context, null);
|
||||
|
|
|
@ -12,8 +12,11 @@ import androidx.annotation.Nullable;
|
|||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.ui.AspectRatioFrameLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
|
||||
|
@ -28,14 +31,15 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
|
|||
/**
|
||||
* Holds a view which will either play back an MP4 gif or show its still.
|
||||
*/
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
final class GiphyMp4ViewHolder extends MappingViewHolder<GiphyImage> implements GiphyMp4Playable {
|
||||
|
||||
private static final Projection.Corners CORNERS = new Projection.Corners(ViewUtil.dpToPx(8));
|
||||
|
||||
private final AspectRatioFrameLayout container;
|
||||
private final ImageView stillImage;
|
||||
private final GiphyMp4Adapter.Callback listener;
|
||||
private final Drawable placeholder;
|
||||
private final AspectRatioFrameLayout container;
|
||||
private final ImageView stillImage;
|
||||
private final GiphyMp4Adapter.Callback listener;
|
||||
private final Drawable placeholder;
|
||||
|
||||
private float aspectRatio;
|
||||
private MediaItem mediaItem;
|
||||
|
|
|
@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.google.android.exoplayer2.util.MimeTypes;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
|
|
@ -7,7 +7,6 @@ import android.content.Intent;
|
|||
import android.content.res.Configuration;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Size;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
|
@ -111,7 +110,7 @@ public final class MediaOverviewPageFragment extends Fragment
|
|||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
|
||||
voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity());
|
||||
voiceNoteMediaController = new VoiceNoteMediaController(requireActivity(), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -10,13 +10,15 @@ import android.view.animation.PathInterpolator
|
|||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.widget.AppCompatImageButton
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.PlayerControlView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.airbnb.lottie.LottieProperty
|
||||
import com.airbnb.lottie.model.KeyPath
|
||||
import com.google.android.exoplayer2.ui.PlayerControlView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
@ -27,6 +29,7 @@ import kotlin.time.toDuration
|
|||
* The bottom bar for the media preview. This includes the standard seek bar as well as playback controls,
|
||||
* but adds forward and share buttons as well as a recyclerview that can be populated with a rail of thumbnails.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class MediaPreviewPlayerControlView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
|
|
|
@ -122,7 +122,7 @@ class MediaPreviewV2Activity : PassphraseRequiredActivity(), VoiceNoteMediaContr
|
|||
viewModel.setIsInSharedAnimation(false)
|
||||
}
|
||||
|
||||
voiceNoteMediaController = VoiceNoteMediaController(this)
|
||||
voiceNoteMediaController = VoiceNoteMediaController(this, false)
|
||||
|
||||
val systemBarColor = ContextCompat.getColor(this, R.color.signal_dark_colorSurface)
|
||||
window.statusBarColor = systemBarColor
|
||||
|
|
|
@ -9,20 +9,22 @@ import android.view.ViewGroup;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.ui.PlayerControlView;
|
||||
|
||||
import com.google.android.exoplayer2.ui.PlayerControlView;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.video.VideoPlayer;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public final class VideoMediaPreviewFragment extends MediaPreviewFragment {
|
||||
|
||||
private static final String TAG = Log.tag(VideoMediaPreviewFragment.class);
|
||||
|
|
|
@ -18,7 +18,7 @@ import androidx.lifecycle.DefaultLifecycleObserver;
|
|||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import androidx.media3.common.MediaItem;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
|
|
@ -4,10 +4,10 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.ThreadUtil
|
||||
|
|
|
@ -81,7 +81,7 @@ class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControll
|
|||
super.onCreate(savedInstanceState, ready)
|
||||
setContentView(R.layout.fragment_container)
|
||||
|
||||
voiceNoteMediaController = VoiceNoteMediaController(this)
|
||||
voiceNoteMediaController = VoiceNoteMediaController(this, false)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
replaceStoryViewerFragment()
|
||||
|
|
|
@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.google.android.exoplayer2.util.MimeTypes;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import com.google.common.io.ByteStreams;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
|
|
@ -26,18 +26,20 @@ import android.widget.FrameLayout;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.Tracks;
|
||||
import com.google.android.exoplayer2.source.ClippingMediaSource;
|
||||
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
import com.google.android.exoplayer2.ui.PlayerControlView;
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.Tracks;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.exoplayer.source.ClippingMediaSource;
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
|
||||
import androidx.media3.exoplayer.source.MediaSource;
|
||||
import androidx.media3.ui.AspectRatioFrameLayout;
|
||||
import androidx.media3.ui.PlayerControlView;
|
||||
import androidx.media3.ui.PlayerView;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -48,12 +50,13 @@ import org.thoughtcrime.securesms.mms.VideoSlide;
|
|||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public class VideoPlayer extends FrameLayout {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(VideoPlayer.class);
|
||||
|
||||
private final StyledPlayerView exoView;
|
||||
private final PlayerView exoView;
|
||||
private final DefaultMediaSourceFactory mediaSourceFactory;
|
||||
|
||||
private ExoPlayer exoPlayer;
|
||||
|
|
|
@ -6,10 +6,13 @@ import android.net.Uri;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSpec;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DataSpec;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
|
||||
|
@ -20,6 +23,7 @@ import java.util.Collections;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
class BlobDataSource implements DataSource {
|
||||
|
||||
private final @NonNull Context context;
|
||||
|
|
|
@ -6,10 +6,12 @@ import android.net.Uri;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSpec;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DataSpec;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.net.ChunkedDataFetcher;
|
||||
|
@ -25,6 +27,7 @@ import okhttp3.OkHttpClient;
|
|||
/**
|
||||
* DataSource which utilizes ChunkedDataFetcher to download video content via Signal content proxy.
|
||||
*/
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
class ChunkedDataSource implements DataSource {
|
||||
|
||||
private final OkHttpClient okHttpClient;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package org.thoughtcrime.securesms.video.exo
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.Player
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
||||
fun ExoPlayer.configureForGifPlayback() {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
|
|
|
@ -7,9 +7,11 @@ import android.net.Uri;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSpec;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DataSpec;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
|
@ -23,6 +25,7 @@ import java.util.Collections;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
class PartDataSource implements DataSource {
|
||||
|
||||
private final @NonNull Context context;
|
||||
|
|
|
@ -5,12 +5,16 @@ import android.net.Uri;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSpec;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DataSpec;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
import androidx.media3.datasource.DefaultDataSourceFactory;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
|
@ -22,11 +26,12 @@ import java.util.Map;
|
|||
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Go-to {@link DataSource} that handles all of our various types of video sources.
|
||||
* Will defer to other {@link DataSource}s depending on the URI.
|
||||
*/
|
||||
public class SignalDataSource implements DataSource {
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public class SignalDataSource implements DataSource {
|
||||
|
||||
private final DefaultDataSource defaultDataSource;
|
||||
private final PartDataSource partDataSource;
|
||||
|
|
|
@ -1,33 +1,26 @@
|
|||
package org.thoughtcrime.securesms.video.exo;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.drm.DrmSessionManager;
|
||||
import com.google.android.exoplayer2.drm.DrmSessionManagerProvider;
|
||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
|
||||
import com.google.android.exoplayer2.extractor.ExtractorsFactory;
|
||||
import com.google.android.exoplayer2.offline.StreamKey;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.source.MediaSourceFactory;
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource;
|
||||
import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
|
||||
|
||||
import java.util.List;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
|
||||
import androidx.media3.exoplayer.source.MediaSource;
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource;
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory;
|
||||
import androidx.media3.extractor.ExtractorsFactory;
|
||||
|
||||
/**
|
||||
* This class is responsible for creating a MediaSource object for a given Uri, using {@link SignalDataSource.Factory}.
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
public final class SignalMediaSourceFactory implements MediaSourceFactory {
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public final class SignalMediaSourceFactory implements MediaSource.Factory {
|
||||
|
||||
private final ProgressiveMediaSource.Factory progressiveMediaSourceFactory;
|
||||
|
||||
|
@ -38,32 +31,19 @@ public final class SignalMediaSourceFactory implements MediaSourceFactory {
|
|||
progressiveMediaSourceFactory = new ProgressiveMediaSource.Factory(attachmentDataSourceFactory, extractorsFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MediaSource for a given MediaDescriptionCompat
|
||||
*
|
||||
* @param description The description to build from
|
||||
*
|
||||
* @return A preparable MediaSource
|
||||
*/
|
||||
public @NonNull MediaSource createMediaSource(MediaDescriptionCompat description) {
|
||||
return progressiveMediaSourceFactory.createMediaSource(
|
||||
new MediaItem.Builder().setUri(description.getMediaUri()).setTag(description).build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaSourceFactory setDrmSessionManagerProvider(@Nullable DrmSessionManagerProvider drmSessionManagerProvider) {
|
||||
public MediaSource.Factory setDrmSessionManagerProvider(@Nullable DrmSessionManagerProvider drmSessionManagerProvider) {
|
||||
return progressiveMediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaSourceFactory setLoadErrorHandlingPolicy(@Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
|
||||
public MediaSource.Factory setLoadErrorHandlingPolicy(@Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
|
||||
return progressiveMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] getSupportedTypes() {
|
||||
return new int[] { C.TYPE_OTHER };
|
||||
return new int[] { C.CONTENT_TYPE_OTHER };
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -2,13 +2,14 @@ package org.thoughtcrime.securesms.video.exo
|
|||
|
||||
import android.content.Context
|
||||
import androidx.annotation.MainThread
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException
|
||||
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.net.ContentProxySelector
|
||||
|
@ -17,8 +18,9 @@ import org.thoughtcrime.securesms.util.DeviceProperties
|
|||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* ExoPlayerPool concrete instance which helps to manage a pool of SimpleExoPlayer objects
|
||||
* ExoPlayerPool concrete instance which helps to manage a pool of ExoPlayer objects
|
||||
*/
|
||||
@OptIn(markerClass = [UnstableApi::class])
|
||||
class SimpleExoPlayerPool(context: Context) : ExoPlayerPool<ExoPlayer>(MAXIMUM_RESERVED_PLAYERS) {
|
||||
private val context: Context = context.applicationContext
|
||||
private val okHttpClient = ApplicationDependencies.getOkHttpClient().newBuilder().proxySelector(ContentProxySelector()).build()
|
||||
|
@ -41,7 +43,7 @@ class SimpleExoPlayerPool(context: Context) : ExoPlayerPool<ExoPlayer>(MAXIMUM_R
|
|||
} else {
|
||||
0
|
||||
}
|
||||
} catch (ignored: DecoderQueryException) {
|
||||
} catch (ignored: MediaCodecUtil.DecoderQueryException) {
|
||||
0
|
||||
}
|
||||
|
||||
|
|
|
@ -7,9 +7,11 @@ import android.widget.ImageView;
|
|||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.ui.AspectRatioFrameLayout;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.DisplayMetricsUtil;
|
||||
|
@ -17,6 +19,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.Factory;
|
|||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
class ChatWallpaperViewHolder extends MappingViewHolder<ChatWallpaperSelectionMappingModel> {
|
||||
|
||||
private final AspectRatioFrameLayout frame;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.media3.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -21,4 +21,4 @@
|
|||
android:visibility="gone"
|
||||
tools:alpha="0.2f"
|
||||
tools:visibility="visible" />
|
||||
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
|
||||
</androidx.media3.ui.AspectRatioFrameLayout>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.media3.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
|
@ -19,4 +19,4 @@
|
|||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.WallpaperPreview"
|
||||
tools:src="@drawable/test_gradient" />
|
||||
|
||||
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
|
||||
</androidx.media3.ui.AspectRatioFrameLayout>
|
|
@ -30,7 +30,7 @@
|
|||
android:paddingRight="4dp"
|
||||
android:textColor="@color/signal_colorOnSurface" />
|
||||
|
||||
<com.google.android.exoplayer2.ui.DefaultTimeBar
|
||||
<androidx.media3.ui.DefaultTimeBar
|
||||
android:id="@id/exo_progress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="55dp"
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
<androidx.media3.ui.AspectRatioFrameLayout
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
|
@ -19,5 +19,5 @@
|
|||
android:scaleType="fitXY"
|
||||
app:shapeAppearanceOverlay="@style/Signal.ShapeOverlay.Rounded" />
|
||||
|
||||
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
|
||||
</androidx.media3.ui.AspectRatioFrameLayout>
|
||||
</FrameLayout>
|
|
@ -6,7 +6,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
<androidx.media3.ui.AspectRatioFrameLayout
|
||||
android:id="@+id/exo_content_frame"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
android:layout_height="match_parent"
|
||||
tools:viewBindingIgnore="true">
|
||||
|
||||
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
<androidx.media3.ui.AspectRatioFrameLayout
|
||||
android:id="@+id/exo_content_frame"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
android:orientation="vertical"
|
||||
tools:viewBindingIgnore="true">
|
||||
|
||||
<com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -1,46 +1,49 @@
|
|||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.any
|
||||
import org.mockito.Mockito.anyBoolean
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.eq
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class VoiceNotePlaybackControllerTest {
|
||||
class VoiceNotePlayerCallbackTest {
|
||||
|
||||
private val mediaSessionCompat = mock(MediaSessionCompat::class.java)
|
||||
private val playbackParameters = VoiceNotePlaybackParameters(mediaSessionCompat)
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private val mediaAudioAttributes = AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
|
||||
private val callAudioAttributes = AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
|
||||
private val player: SimpleExoPlayer = mock(SimpleExoPlayer::class.java)
|
||||
private val testSubject = VoiceNotePlaybackController(player, playbackParameters)
|
||||
private val session = mock(MediaSession::class.java)
|
||||
private val controllerInfo = mock(MediaSession.ControllerInfo::class.java)
|
||||
private val player: VoiceNotePlayer = mock(VoiceNotePlayer::class.java)
|
||||
private val testSubject = VoiceNotePlayerCallback(context, player)
|
||||
|
||||
@Test
|
||||
fun `Given stream is media, When I onCommand for voice, then I expect the stream to switch to voice and continue playback`() {
|
||||
// GIVEN
|
||||
`when`(player.audioAttributes).thenReturn(mediaAudioAttributes)
|
||||
|
||||
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
|
||||
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
|
||||
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_VOICE_CALL) }
|
||||
val expected = callAudioAttributes
|
||||
|
||||
// WHEN
|
||||
testSubject.onCommand(player, command, extras, null)
|
||||
testSubject.onCustomCommand(session, controllerInfo, command, extras)
|
||||
|
||||
// THEN
|
||||
verify(player).playWhenReady = false
|
||||
|
@ -53,12 +56,12 @@ class VoiceNotePlaybackControllerTest {
|
|||
// GIVEN
|
||||
`when`(player.audioAttributes).thenReturn(callAudioAttributes)
|
||||
|
||||
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
|
||||
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
|
||||
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) }
|
||||
val expected = mediaAudioAttributes
|
||||
|
||||
// WHEN
|
||||
testSubject.onCommand(player, command, extras, null)
|
||||
testSubject.onCustomCommand(session, controllerInfo, command, extras)
|
||||
|
||||
// THEN
|
||||
verify(player).playWhenReady = false
|
||||
|
@ -71,11 +74,11 @@ class VoiceNotePlaybackControllerTest {
|
|||
// GIVEN
|
||||
`when`(player.audioAttributes).thenReturn(callAudioAttributes)
|
||||
|
||||
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
|
||||
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
|
||||
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_VOICE_CALL) }
|
||||
|
||||
// WHEN
|
||||
testSubject.onCommand(player, command, extras, null)
|
||||
testSubject.onCustomCommand(session, controllerInfo, command, extras)
|
||||
|
||||
// THEN
|
||||
verify(player, Mockito.never()).playWhenReady = anyBoolean()
|
||||
|
@ -87,11 +90,11 @@ class VoiceNotePlaybackControllerTest {
|
|||
// GIVEN
|
||||
`when`(player.audioAttributes).thenReturn(mediaAudioAttributes)
|
||||
|
||||
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
|
||||
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
|
||||
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) }
|
||||
|
||||
// WHEN
|
||||
testSubject.onCommand(player, command, extras, null)
|
||||
testSubject.onCustomCommand(session, controllerInfo, command, extras)
|
||||
|
||||
// THEN
|
||||
verify(player, Mockito.never()).playWhenReady = anyBoolean()
|
|
@ -1,6 +1,6 @@
|
|||
package org.thoughtcrime.securesms.video.exo
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
|
|
|
@ -9,6 +9,7 @@ dependencyResolutionManagement {
|
|||
version('androidx-camera', '1.2.3')
|
||||
version('androidx-fragment', '1.6.1')
|
||||
version('androidx-lifecycle', '2.6.1')
|
||||
version('androidx-media3', '1.1.0')
|
||||
version('androidx-navigation', '2.6.0')
|
||||
version('androidx-window', '1.0.0')
|
||||
version('exoplayer', '2.19.0')
|
||||
|
@ -60,6 +61,9 @@ dependencyResolutionManagement {
|
|||
library('androidx-preference', 'androidx.preference:preference:1.0.0')
|
||||
library('androidx-gridlayout', 'androidx.gridlayout:gridlayout:1.0.0')
|
||||
library('androidx-exifinterface', 'androidx.exifinterface:exifinterface:1.3.3')
|
||||
library('androidx-media3-exoplayer', 'androidx.media3', 'media3-exoplayer').versionRef('androidx-media3')
|
||||
library('androidx-media3-session', 'androidx.media3', 'media3-session').versionRef('androidx-media3')
|
||||
library('androidx-media3-ui', 'androidx.media3', 'media3-ui').versionRef('androidx-media3')
|
||||
library('androidx-multidex', 'androidx.multidex:multidex:2.0.1')
|
||||
library('androidx-navigation-fragment-ktx', 'androidx.navigation', 'navigation-fragment-ktx').versionRef('androidx-navigation')
|
||||
library('androidx-navigation-ui-ktx', 'androidx.navigation', 'navigation-ui-ktx').versionRef('androidx-navigation')
|
||||
|
@ -100,11 +104,7 @@ dependencyResolutionManagement {
|
|||
library('google-guava-android', 'com.google.guava:guava:30.0-android')
|
||||
library('google-flexbox', 'com.google.android.flexbox:flexbox:3.0.0')
|
||||
|
||||
// Exoplayer
|
||||
library('exoplayer-core', 'com.google.android.exoplayer', 'exoplayer-core').versionRef('exoplayer')
|
||||
library('exoplayer-ui', 'com.google.android.exoplayer', 'exoplayer-ui').versionRef('exoplayer')
|
||||
library('exoplayer-extension-mediasession', 'com.google.android.exoplayer', 'extension-mediasession').versionRef('exoplayer')
|
||||
bundle('exoplayer', ['exoplayer-core', 'exoplayer-ui', 'exoplayer-extension-mediasession'])
|
||||
bundle('media3', ['androidx-media3-exoplayer', 'androidx-media3-session', 'androidx-media3-ui'])
|
||||
|
||||
// Firebase
|
||||
library('firebase-messaging', 'com.google.firebase:firebase-messaging:23.1.2')
|
||||
|
|
|
@ -328,6 +328,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
|||
<sha256 value="632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.collection" name="collection" version="1.2.0">
|
||||
<artifact name="collection-1.2.0.jar">
|
||||
<sha256 value="16d77e8c443fa55fe9a6074d00445d520ca5c9f913cefdbf4828356255214e42" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="collection-1.2.0.module">
|
||||
<sha256 value="6c4c0f9e7dab6d983858e67dd99a93af9d9f7d02ac1f8f648ce1fecc84afffa2" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.collection" name="collection-ktx" version="1.1.0">
|
||||
<artifact name="collection-ktx-1.1.0.jar">
|
||||
<sha256 value="2bfc54475c047131913361f56d0f7f019c6e5bee53eeb0eb7d94a7c499a05227" origin="Generated by Gradle"/>
|
||||
|
@ -1045,6 +1053,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
|||
<sha256 value="e71c328ceef5c4a7d76f2d86df1b65d65fe2acf868b1a4efd84a3f34336186d8" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media" name="media" version="1.0.0">
|
||||
<artifact name="media-1.0.0.aar">
|
||||
<sha256 value="b23b527b2bac870c4a7451e6982d7132e413e88d7f27dbeb1fc7640a720cd9ee" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media" name="media" version="1.4.3">
|
||||
<artifact name="media-1.4.3.aar">
|
||||
<sha256 value="89a83d648dafeb5160a76dcdf996735d746f410c115166cf15693b5208d73d95" origin="Generated by Gradle"/>
|
||||
|
@ -1061,6 +1074,78 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
|||
<sha256 value="44d12bf8f7ce879f03af00ba01b556751d0073440fed1150128e903974c4def0" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-common" version="1.1.0">
|
||||
<artifact name="media3-common-1.1.0.aar">
|
||||
<sha256 value="1dd31798aa20f040528d3c900dc262122a3f4657ac96f32dcbb4c8560ca8310d" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-common-1.1.0.module">
|
||||
<sha256 value="33aab3e709760bde1102c80768d934f4cb3f70cdb6ff8d980f70b0d5d9f24e1f" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-container" version="1.1.0">
|
||||
<artifact name="media3-container-1.1.0.aar">
|
||||
<sha256 value="22c06a510447df925ca51a974ea3fdb278f927392881189346f720be9c61fa1c" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-container-1.1.0.module">
|
||||
<sha256 value="b739cf49445bf078460c84ad2d1da0b84d4235624a41e59f1fa33b43a54cfe3f" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-database" version="1.1.0">
|
||||
<artifact name="media3-database-1.1.0.aar">
|
||||
<sha256 value="5fe92576a8bfa75af43c7f0f44b51c16d65dbb682c2f20c89e789ef7b695fdd7" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-database-1.1.0.module">
|
||||
<sha256 value="e4ec2441bf55622466fbeef97d1bf09264312acb09576dd8d7a8d5331a2740f1" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-datasource" version="1.1.0">
|
||||
<artifact name="media3-datasource-1.1.0.aar">
|
||||
<sha256 value="726447a737073feaffe32a71607aff10d0e078593e6040ea9c0eac8656bd3814" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-datasource-1.1.0.module">
|
||||
<sha256 value="b53c6041466b97dc75d6c9604113bffccc43444470e47130ce510f4804e00ef0" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-decoder" version="1.1.0">
|
||||
<artifact name="media3-decoder-1.1.0.aar">
|
||||
<sha256 value="fdecf3e7ef2dec69dddaa6958506158b1ccbae3c83542b46448d595727510f5f" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-decoder-1.1.0.module">
|
||||
<sha256 value="8560ac3db7a314eb46ce026dd254ebdc7373f5e82b8a40f5b7930aa16c6da5f9" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-exoplayer" version="1.1.0">
|
||||
<artifact name="media3-exoplayer-1.1.0.aar">
|
||||
<sha256 value="ec7b813907df1c756d39c39be392097289a1e6915f0e8c986fc6772d2dfd38a2" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-exoplayer-1.1.0.module">
|
||||
<sha256 value="95f918a5a224f19e69c81bfe9917693875da28f555f84b03459347373802475a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-extractor" version="1.1.0">
|
||||
<artifact name="media3-extractor-1.1.0.aar">
|
||||
<sha256 value="5d6a3e089701450fa489a604f92580011d21c54fe7eeb3fee067efc541e4e58a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-extractor-1.1.0.module">
|
||||
<sha256 value="5c7156143dcdce93c4b35a6cf9bd2bd34a9b4d003cb6ec2e0cebe5e9623b5ba5" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-session" version="1.1.0">
|
||||
<artifact name="media3-session-1.1.0.aar">
|
||||
<sha256 value="abae9db48fa3907b833aaddcc6c545c4f86bfa6a03aae3ad9de75a049daed604" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-session-1.1.0.module">
|
||||
<sha256 value="f210e40037916a1e8d2b4b63c2467573db2323a97ff45007e5c7a3b5d73c4bd3" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-ui" version="1.1.0">
|
||||
<artifact name="media3-ui-1.1.0.aar">
|
||||
<sha256 value="231359bf75f653cb3574b21bbde29ac511cf2516a3b14044bdfb25a36ad502c7" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="media3-ui-1.1.0.module">
|
||||
<sha256 value="9ee4eabef9c5658681e79dc2bb2b6bc931111e25c33b53be1b958e5d4a309392" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.multidex" name="multidex" version="2.0.1">
|
||||
<artifact name="multidex-2.0.1.aar">
|
||||
<sha256 value="42dd32ff9f97f85771b82a20003a8d70f68ab7b4ba328964312ce0732693db09" origin="Generated by Gradle"/>
|
||||
|
@ -2259,78 +2344,6 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
|||
<sha256 value="cb9353ef1791ae17097d878ca711e25a9c32cec9042adc49b00cadfee1a7290b" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-common" version="2.19.0">
|
||||
<artifact name="exoplayer-common-2.19.0.aar">
|
||||
<sha256 value="275b1e632ea0c84c72d1dbf7d269dac56b16b91143ada275e839f43b3c0957c3" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-common-2.19.0.module">
|
||||
<sha256 value="4bc5417380075718238a6d2a9ef793eb924947d82c237e6fe92ff1625440aa8d" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-container" version="2.19.0">
|
||||
<artifact name="exoplayer-container-2.19.0.aar">
|
||||
<sha256 value="f8d44b158d5f43a1da1912238c302b2c26ab8034344e94c5e58b673fd0327048" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-container-2.19.0.module">
|
||||
<sha256 value="0509716ed4e8616622d26f44dde77f787c53e4038153579c4a52411a319d96e3" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-core" version="2.19.0">
|
||||
<artifact name="exoplayer-core-2.19.0.aar">
|
||||
<sha256 value="da783e26eb7033ff33124185b50aee01f2b02234d12222b99e1e012e24c1f94d" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-core-2.19.0.module">
|
||||
<sha256 value="8c7182e88880516b9e93a81033a5d8238be0381e41da0f462e48451c21e9526f" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-database" version="2.19.0">
|
||||
<artifact name="exoplayer-database-2.19.0.aar">
|
||||
<sha256 value="e3b0a1b33f1392682bb59cc3c88e6ae02538a172880a4466542ba2ed902e8229" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-database-2.19.0.module">
|
||||
<sha256 value="4e55bcf14ac65f9c06beb443a71de208b7203c7eb8f3c4dd1c51487a31db1e56" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-datasource" version="2.19.0">
|
||||
<artifact name="exoplayer-datasource-2.19.0.aar">
|
||||
<sha256 value="ae62f419c7da8a37530f2c507ba303cc8166ba4b56b34326b08ed6ea546a120d" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-datasource-2.19.0.module">
|
||||
<sha256 value="d0e12555b89a63e505a472620341d1b1900520b9d2bc25b3c8530a9db79ffb82" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-decoder" version="2.19.0">
|
||||
<artifact name="exoplayer-decoder-2.19.0.aar">
|
||||
<sha256 value="1222e92cb18e041ed998f056f0226f5101dc22faa4768e63f9148f99484de3b6" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-decoder-2.19.0.module">
|
||||
<sha256 value="d24ec7c397d53372866ff3e6886ec65788841863d676dea151094d5459918684" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-extractor" version="2.19.0">
|
||||
<artifact name="exoplayer-extractor-2.19.0.aar">
|
||||
<sha256 value="3e5536939c89220bfd844e332226fea9c1bd7d65ff2a51d7d613652744bb3f6c" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-extractor-2.19.0.module">
|
||||
<sha256 value="73a7505d13669d60bdc962ec093c1b76c181b33ea27614a7468d32400793a521" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="exoplayer-ui" version="2.19.0">
|
||||
<artifact name="exoplayer-ui-2.19.0.aar">
|
||||
<sha256 value="0e95e2ef646a934a95b94f4fa0e50eec1fa420294b166d4a76bf116658a55459" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="exoplayer-ui-2.19.0.module">
|
||||
<sha256 value="538e820c54b958d661e1d8a9534c20081fda6a9992c23e6a39ad56d686e4e618" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.exoplayer" name="extension-mediasession" version="2.19.0">
|
||||
<artifact name="extension-mediasession-2.19.0.aar">
|
||||
<sha256 value="705148834535331c90b39836240d1cc9501423f762275ad3b784c1e88e2e94f6" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="extension-mediasession-2.19.0.module">
|
||||
<sha256 value="7c3e8f972b1c842fa99b8ca356ebbc70a2afda03ff827a50a456754e4c93e213" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.flexbox" name="flexbox" version="3.0.0">
|
||||
<artifact name="flexbox-3.0.0.aar">
|
||||
<sha256 value="5e19500486fd7c8e2e8c7aad6bbba9c8d0ada7057c6b32b9b5c61439814e7574" origin="Generated by Gradle"/>
|
||||
|
|
Loading…
Add table
Reference in a new issue