AudioPlayer.java

  1package eu.siacs.conversations.ui.service;
  2
  3import android.Manifest;
  4import android.content.Context;
  5import android.content.pm.PackageManager;
  6import android.content.res.ColorStateList;
  7import android.hardware.Sensor;
  8import android.hardware.SensorEvent;
  9import android.hardware.SensorEventListener;
 10import android.hardware.SensorManager;
 11import android.media.AudioManager;
 12import android.os.Build;
 13import android.os.Handler;
 14import android.os.PowerManager;
 15import android.util.Log;
 16import android.view.View;
 17import android.widget.RelativeLayout;
 18import android.widget.SeekBar;
 19import android.widget.TextView;
 20import androidx.core.app.ActivityCompat;
 21import androidx.core.content.ContextCompat;
 22import com.google.android.material.button.MaterialButton;
 23import com.google.common.primitives.Ints;
 24import eu.siacs.conversations.Config;
 25import eu.siacs.conversations.R;
 26import eu.siacs.conversations.entities.Message;
 27import eu.siacs.conversations.services.MediaPlayer;
 28import eu.siacs.conversations.ui.ConversationsActivity;
 29import eu.siacs.conversations.ui.adapter.MessageAdapter;
 30import eu.siacs.conversations.ui.util.PendingItem;
 31import eu.siacs.conversations.utils.TimeFrameUtils;
 32import eu.siacs.conversations.utils.WeakReferenceSet;
 33import java.lang.ref.WeakReference;
 34import java.util.concurrent.ExecutorService;
 35import java.util.concurrent.Executors;
 36
 37public class AudioPlayer
 38        implements View.OnClickListener,
 39                MediaPlayer.OnCompletionListener,
 40                SeekBar.OnSeekBarChangeListener,
 41                Runnable,
 42                SensorEventListener {
 43
 44    private static final int REFRESH_INTERVAL = 250;
 45    private static final Object LOCK = new Object();
 46    private static MediaPlayer player = null;
 47    private static Message currentlyPlayingMessage = null;
 48    private static PowerManager.WakeLock wakeLock;
 49    private final MessageAdapter messageAdapter;
 50    private final WeakReferenceSet<RelativeLayout> audioPlayerLayouts = new WeakReferenceSet<>();
 51    private final SensorManager sensorManager;
 52    private final Sensor proximitySensor;
 53    private final PendingItem<WeakReference<MaterialButton>> pendingOnClickView =
 54            new PendingItem<>();
 55
 56    private final ExecutorService executor = Executors.newSingleThreadExecutor();
 57
 58    private final Handler handler = new Handler();
 59
 60    public AudioPlayer(MessageAdapter adapter) {
 61        final Context context = adapter.getContext();
 62        this.messageAdapter = adapter;
 63        this.sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
 64        this.proximitySensor =
 65                this.sensorManager == null
 66                        ? null
 67                        : this.sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
 68        initializeProximityWakeLock(context);
 69        synchronized (AudioPlayer.LOCK) {
 70            if (AudioPlayer.player != null) {
 71                AudioPlayer.player.setOnCompletionListener(this);
 72                if (AudioPlayer.player.isPlaying() && sensorManager != null) {
 73                    sensorManager.registerListener(
 74                            this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
 75                }
 76            }
 77        }
 78    }
 79
 80    private static String formatTime(final int ms) {
 81        return TimeFrameUtils.formatElapsedTime(ms, false);
 82    }
 83
 84    private void initializeProximityWakeLock(Context context) {
 85        synchronized (AudioPlayer.LOCK) {
 86            if (AudioPlayer.wakeLock == null) {
 87                final PowerManager powerManager =
 88                        (PowerManager) context.getSystemService(Context.POWER_SERVICE);
 89                AudioPlayer.wakeLock =
 90                        powerManager == null
 91                                ? null
 92                                : powerManager.newWakeLock(
 93                                        PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
 94                                        AudioPlayer.class.getSimpleName());
 95                AudioPlayer.wakeLock.setReferenceCounted(false);
 96            }
 97        }
 98    }
 99
100    public void init(RelativeLayout audioPlayer, Message message) {
101        synchronized (AudioPlayer.LOCK) {
102            audioPlayer.setTag(message);
103            if (init(ViewHolder.get(audioPlayer), message)) {
104                this.audioPlayerLayouts.addWeakReferenceTo(audioPlayer);
105                executor.execute(() -> this.stopRefresher(true));
106            } else {
107                this.audioPlayerLayouts.removeWeakReferenceTo(audioPlayer);
108            }
109        }
110    }
111
112    private boolean init(final ViewHolder viewHolder, final Message message) {
113        MessageAdapter.setTextColor(viewHolder.runtime, viewHolder.bubbleColor);
114        viewHolder.progress.setOnSeekBarChangeListener(this);
115        final ColorStateList color =
116                MessageAdapter.bubbleToOnSurfaceColorStateList(
117                        viewHolder.progress, viewHolder.bubbleColor);
118        viewHolder.progress.setThumbTintList(color);
119        viewHolder.progress.setProgressTintList(color);
120        viewHolder.playPause.setOnClickListener(this);
121        final Context context = viewHolder.playPause.getContext();
122        if (message == currentlyPlayingMessage) {
123            if (AudioPlayer.player != null && AudioPlayer.player.isPlaying()) {
124                viewHolder.playPause.setIconResource(R.drawable.ic_pause_24dp);
125                viewHolder.playPause.setContentDescription(context.getString(R.string.pause_audio));
126                viewHolder.progress.setEnabled(true);
127            } else {
128                viewHolder.playPause.setContentDescription(context.getString(R.string.play_audio));
129                viewHolder.playPause.setIconResource(R.drawable.ic_play_arrow_24dp);
130                viewHolder.progress.setEnabled(false);
131            }
132            return true;
133        } else {
134            viewHolder.playPause.setIconResource(R.drawable.ic_play_arrow_24dp);
135            viewHolder.playPause.setContentDescription(context.getString(R.string.play_audio));
136            viewHolder.runtime.setText(formatTime(message.getFileParams().runtime));
137            viewHolder.progress.setProgress(0);
138            viewHolder.progress.setEnabled(false);
139            return false;
140        }
141    }
142
143    @Override
144    public synchronized void onClick(View v) {
145        if (v.getId() == R.id.play_pause) {
146            synchronized (LOCK) {
147                startStop((MaterialButton) v);
148            }
149        }
150    }
151
152    private void startStop(final MaterialButton playPause) {
153        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
154                && ContextCompat.checkSelfPermission(
155                                messageAdapter.getActivity(),
156                                Manifest.permission.WRITE_EXTERNAL_STORAGE)
157                        != PackageManager.PERMISSION_GRANTED) {
158            pendingOnClickView.push(new WeakReference<>(playPause));
159            ActivityCompat.requestPermissions(
160                    messageAdapter.getActivity(),
161                    new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
162                    ConversationsActivity.REQUEST_PLAY_PAUSE);
163            return;
164        }
165        initializeProximityWakeLock(playPause.getContext());
166        final RelativeLayout audioPlayer = (RelativeLayout) playPause.getParent();
167        final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
168        final Message message = (Message) audioPlayer.getTag();
169        if (startStop(viewHolder, message)) {
170            this.audioPlayerLayouts.clear();
171            this.audioPlayerLayouts.addWeakReferenceTo(audioPlayer);
172            stopRefresher(true);
173        }
174    }
175
176    private boolean playPauseCurrent(final ViewHolder viewHolder) {
177        final Context context = viewHolder.playPause.getContext();
178        if (player.isPlaying()) {
179            viewHolder.progress.setEnabled(false);
180            player.pause();
181            messageAdapter.flagScreenOff();
182            releaseProximityWakeLock();
183            viewHolder.playPause.setIconResource(R.drawable.ic_play_arrow_24dp);
184            viewHolder.playPause.setContentDescription(context.getString(R.string.play_audio));
185        } else {
186            viewHolder.progress.setEnabled(true);
187            player.start();
188            messageAdapter.flagScreenOn();
189            acquireProximityWakeLock();
190            this.stopRefresher(true);
191            viewHolder.playPause.setIconResource(R.drawable.ic_pause_24dp);
192            viewHolder.playPause.setContentDescription(context.getString(R.string.pause_audio));
193        }
194        return false;
195    }
196
197    private void play(ViewHolder viewHolder, Message message, boolean earpiece, double progress) {
198        if (play(viewHolder, message, earpiece)) {
199            AudioPlayer.player.seekTo((int) (AudioPlayer.player.getDuration() * progress));
200        }
201    }
202
203    private boolean play(ViewHolder viewHolder, Message message, boolean earpiece) {
204        AudioPlayer.player = new MediaPlayer();
205        try {
206            AudioPlayer.currentlyPlayingMessage = message;
207            AudioPlayer.player.setAudioStreamType(
208                    earpiece ? AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC);
209            AudioPlayer.player.setDataSource(
210                    messageAdapter.getFileBackend().getFile(message).getAbsolutePath());
211            AudioPlayer.player.setOnCompletionListener(this);
212            AudioPlayer.player.prepare();
213            AudioPlayer.player.start();
214            messageAdapter.flagScreenOn();
215            acquireProximityWakeLock();
216            viewHolder.progress.setEnabled(true);
217            viewHolder.playPause.setIconResource(R.drawable.ic_pause_24dp);
218            viewHolder.playPause.setContentDescription(
219                    viewHolder.playPause.getContext().getString(R.string.pause_audio));
220            sensorManager.registerListener(
221                    this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
222            return true;
223        } catch (final Exception e) {
224            messageAdapter.flagScreenOff();
225            releaseProximityWakeLock();
226            AudioPlayer.currentlyPlayingMessage = null;
227            sensorManager.unregisterListener(this);
228            return false;
229        }
230    }
231
232    public void startStopPending() {
233        final var reference = pendingOnClickView.pop();
234        if (reference != null) {
235            var imageButton = reference.get();
236            if (imageButton != null) {
237                startStop(imageButton);
238            }
239        }
240    }
241
242    private boolean startStop(ViewHolder viewHolder, Message message) {
243        if (message == currentlyPlayingMessage && player != null) {
244            return playPauseCurrent(viewHolder);
245        }
246        if (AudioPlayer.player != null) {
247            stopCurrent();
248        }
249        return play(viewHolder, message, false);
250    }
251
252    private void stopCurrent() {
253        if (AudioPlayer.player.isPlaying()) {
254            AudioPlayer.player.stop();
255        }
256        AudioPlayer.player.release();
257        messageAdapter.flagScreenOff();
258        releaseProximityWakeLock();
259        AudioPlayer.player = null;
260        resetPlayerUi();
261    }
262
263    private void resetPlayerUi() {
264        for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
265            resetPlayerUi(audioPlayer.get());
266        }
267    }
268
269    private void resetPlayerUi(final RelativeLayout audioPlayer) {
270        if (audioPlayer == null) {
271            return;
272        }
273        final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
274        final Message message = (Message) audioPlayer.getTag();
275        viewHolder.playPause.setContentDescription(
276                viewHolder.playPause.getContext().getString(R.string.play_audio));
277        viewHolder.playPause.setIconResource(R.drawable.ic_play_arrow_24dp);
278        if (message != null) {
279            viewHolder.runtime.setText(formatTime(message.getFileParams().runtime));
280        }
281        viewHolder.progress.setProgress(0);
282        viewHolder.progress.setEnabled(false);
283    }
284
285    @Override
286    public void onCompletion(android.media.MediaPlayer mediaPlayer) {
287        synchronized (AudioPlayer.LOCK) {
288            this.stopRefresher(false);
289            if (AudioPlayer.player == mediaPlayer) {
290                AudioPlayer.currentlyPlayingMessage = null;
291                AudioPlayer.player = null;
292            }
293            mediaPlayer.release();
294            messageAdapter.flagScreenOff();
295            releaseProximityWakeLock();
296            resetPlayerUi();
297            sensorManager.unregisterListener(this);
298        }
299    }
300
301    @Override
302    public void onProgressChanged(
303            final SeekBar seekBar, final int progress, final boolean fromUser) {
304        synchronized (AudioPlayer.LOCK) {
305            final RelativeLayout audioPlayer = (RelativeLayout) seekBar.getParent();
306            final Message message = (Message) audioPlayer.getTag();
307            if (fromUser && message == AudioPlayer.currentlyPlayingMessage) {
308                float percent = progress / 100f;
309                int duration = AudioPlayer.player.getDuration();
310                int seekTo = Math.round(duration * percent);
311                AudioPlayer.player.seekTo(seekTo);
312            }
313        }
314    }
315
316    @Override
317    public void onStartTrackingTouch(SeekBar seekBar) {}
318
319    @Override
320    public void onStopTrackingTouch(SeekBar seekBar) {}
321
322    public void stop() {
323        synchronized (AudioPlayer.LOCK) {
324            stopRefresher(false);
325            if (AudioPlayer.player != null) {
326                stopCurrent();
327            }
328            AudioPlayer.currentlyPlayingMessage = null;
329            sensorManager.unregisterListener(this);
330            if (wakeLock != null && wakeLock.isHeld()) {
331                wakeLock.release();
332            }
333            wakeLock = null;
334        }
335    }
336
337    private void stopRefresher(boolean runOnceMore) {
338        this.handler.removeCallbacks(this);
339        if (runOnceMore) {
340            this.handler.post(this);
341        }
342    }
343
344    public void unregisterListener() {
345        if (sensorManager != null) {
346            sensorManager.unregisterListener(this);
347        }
348    }
349
350    @Override
351    public void run() {
352        synchronized (AudioPlayer.LOCK) {
353            if (AudioPlayer.player != null) {
354                boolean renew = false;
355                final int current = player.getCurrentPosition();
356                final int duration = player.getDuration();
357                for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
358                    renew |= refreshAudioPlayer(audioPlayer.get(), current, duration);
359                }
360                if (renew && AudioPlayer.player.isPlaying()) {
361                    handler.postDelayed(this, REFRESH_INTERVAL);
362                }
363            }
364        }
365    }
366
367    private boolean refreshAudioPlayer(RelativeLayout audioPlayer, int current, int duration) {
368        if (audioPlayer == null || audioPlayer.getVisibility() != View.VISIBLE) {
369            return false;
370        }
371        final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
372        if (duration <= 0) {
373            viewHolder.progress.setProgress(100);
374        } else {
375            final var progress = current * 100L / duration;
376            viewHolder.progress.setProgress(Math.min(Ints.saturatedCast(progress), 100));
377        }
378        viewHolder.runtime.setText(
379                String.format("%s / %s", formatTime(current), formatTime(duration)));
380        return true;
381    }
382
383    @Override
384    public void onSensorChanged(SensorEvent event) {
385        if (event.sensor.getType() != Sensor.TYPE_PROXIMITY) {
386            return;
387        }
388        if (AudioPlayer.player == null || !AudioPlayer.player.isPlaying()) {
389            return;
390        }
391        final int streamType;
392        if (event.values[0] < 5f && event.values[0] != proximitySensor.getMaximumRange()) {
393            streamType = AudioManager.STREAM_VOICE_CALL;
394        } else {
395            streamType = AudioManager.STREAM_MUSIC;
396        }
397        messageAdapter.setVolumeControl(streamType);
398        double position = AudioPlayer.player.getCurrentPosition();
399        double duration = AudioPlayer.player.getDuration();
400        double progress = position / duration;
401        if (AudioPlayer.player.getAudioStreamType() != streamType) {
402            synchronized (AudioPlayer.LOCK) {
403                AudioPlayer.player.stop();
404                AudioPlayer.player.release();
405                AudioPlayer.player = null;
406                try {
407                    ViewHolder currentViewHolder = getCurrentViewHolder();
408                    if (currentViewHolder != null) {
409                        play(
410                                currentViewHolder,
411                                currentlyPlayingMessage,
412                                streamType == AudioManager.STREAM_VOICE_CALL,
413                                progress);
414                    }
415                } catch (Exception e) {
416                    Log.w(Config.LOGTAG, e);
417                }
418            }
419        }
420    }
421
422    @Override
423    public void onAccuracyChanged(Sensor sensor, int i) {}
424
425    private void acquireProximityWakeLock() {
426        synchronized (AudioPlayer.LOCK) {
427            if (wakeLock != null) {
428                wakeLock.acquire();
429            }
430        }
431    }
432
433    private void releaseProximityWakeLock() {
434        synchronized (AudioPlayer.LOCK) {
435            if (wakeLock != null && wakeLock.isHeld()) {
436                wakeLock.release();
437            }
438        }
439        messageAdapter.setVolumeControl(AudioManager.STREAM_MUSIC);
440    }
441
442    private ViewHolder getCurrentViewHolder() {
443        for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
444            final Message message = (Message) audioPlayer.get().getTag();
445            if (message == currentlyPlayingMessage) {
446                return ViewHolder.get(audioPlayer.get());
447            }
448        }
449        return null;
450    }
451
452    public static class ViewHolder {
453        private TextView runtime;
454        private SeekBar progress;
455        private MaterialButton playPause;
456        private MessageAdapter.BubbleColor bubbleColor = MessageAdapter.BubbleColor.SURFACE;
457
458        public static ViewHolder get(final RelativeLayout audioPlayer) {
459            final var existingViewHolder =
460                    (ViewHolder) audioPlayer.getTag(R.id.TAG_AUDIO_PLAYER_VIEW_HOLDER);
461            if (existingViewHolder != null) {
462                return existingViewHolder;
463            }
464            final ViewHolder viewHolder = new ViewHolder();
465            viewHolder.runtime = audioPlayer.findViewById(R.id.runtime);
466            viewHolder.progress = audioPlayer.findViewById(R.id.progress);
467            viewHolder.playPause = audioPlayer.findViewById(R.id.play_pause);
468            audioPlayer.setTag(R.id.TAG_AUDIO_PLAYER_VIEW_HOLDER, viewHolder);
469            return viewHolder;
470        }
471
472        public void setBubbleColor(final MessageAdapter.BubbleColor bubbleColor) {
473            this.bubbleColor = bubbleColor;
474        }
475    }
476}