play audio files inline

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java   |   4 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java |  69 
src/main/java/eu/siacs/conversations/ui/service/AudioPlayer.java    | 280 
src/main/java/eu/siacs/conversations/utils/WeakReferenceSet.java    |  26 
src/main/res/drawable-hdpi/ic_pause_black_36dp.png                  |   0 
src/main/res/drawable-hdpi/ic_pause_white_36dp.png                  |   0 
src/main/res/drawable-hdpi/ic_play_arrow_black_36dp.png             |   0 
src/main/res/drawable-hdpi/ic_play_arrow_white_36dp.png             |   0 
src/main/res/drawable-mdpi/ic_pause_black_36dp.png                  |   0 
src/main/res/drawable-mdpi/ic_pause_white_36dp.png                  |   0 
src/main/res/drawable-mdpi/ic_play_arrow_black_36dp.png             |   0 
src/main/res/drawable-mdpi/ic_play_arrow_white_36dp.png             |   0 
src/main/res/drawable-xhdpi/ic_pause_black_36dp.png                 |   0 
src/main/res/drawable-xhdpi/ic_pause_white_36dp.png                 |   0 
src/main/res/drawable-xhdpi/ic_play_arrow_black_36dp.png            |   0 
src/main/res/drawable-xhdpi/ic_play_arrow_white_36dp.png            |   0 
src/main/res/drawable-xxhdpi/ic_pause_black_36dp.png                |   0 
src/main/res/drawable-xxhdpi/ic_pause_white_36dp.png                |   0 
src/main/res/drawable-xxhdpi/ic_play_arrow_black_36dp.png           |   0 
src/main/res/drawable-xxhdpi/ic_play_arrow_white_36dp.png           |   0 
src/main/res/drawable-xxxhdpi/ic_pause_black_36dp.png               |   0 
src/main/res/drawable-xxxhdpi/ic_pause_white_36dp.png               |   0 
src/main/res/drawable-xxxhdpi/ic_play_arrow_black_36dp.png          |   0 
src/main/res/drawable-xxxhdpi/ic_play_arrow_white_36dp.png          |   0 
src/main/res/layout/message_content.xml                             |  37 
src/main/res/values/ids.xml                                         |   1 
26 files changed, 395 insertions(+), 22 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -868,6 +868,9 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 	@Override
 	public void onStop() {
 		super.onStop();
+		if (activity == null || !activity.isChangingConfigurations()) {
+			messageListAdapter.stopAudioPlayer();
+		}
 		if (this.conversation != null) {
 			final String msg = mEditMessage.getText().toString();
 			this.conversation.setNextMessage(msg);
@@ -894,6 +897,7 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 			this.conversation.setNextMessage(msg);
 			if (this.conversation != conversation) {
 				updateChatState(this.conversation, msg);
+				messageListAdapter.stopAudioPlayer();
 			}
 			this.conversation.trim();
 

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -33,6 +33,7 @@ import android.widget.ArrayAdapter;
 import android.widget.Button;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
@@ -59,6 +60,7 @@ import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.MessageArchiveService;
 import eu.siacs.conversations.services.NotificationService;
 import eu.siacs.conversations.ui.ConversationActivity;
+import eu.siacs.conversations.ui.service.AudioPlayer;
 import eu.siacs.conversations.ui.text.DividerSpan;
 import eu.siacs.conversations.ui.text.QuoteSpan;
 import eu.siacs.conversations.ui.widget.ClickableMovementMethod;
@@ -120,8 +122,11 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 
 	private final ListSelectionManager listSelectionManager = new ListSelectionManager();
 
+	private final AudioPlayer audioPlayer;
+
 	public MessageAdapter(ConversationActivity activity, List<Message> messages) {
 		super(activity, 0, messages);
+		this.audioPlayer = new AudioPlayer(this);
 		this.activity = activity;
 		metrics = getContext().getResources().getDisplayMetrics();
 		updatePreferences();
@@ -164,7 +169,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 		return this.getItemViewType(getItem(position));
 	}
 
-	private int getMessageTextColor(boolean onDark, boolean primary) {
+	public int getMessageTextColor(boolean onDark, boolean primary) {
 		if (onDark) {
 			return ContextCompat.getColor(activity, primary ? R.color.white : R.color.white70);
 		} else {
@@ -299,9 +304,8 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 	}
 
 	private void displayInfoMessage(ViewHolder viewHolder, String text, boolean darkBackground) {
-		if (viewHolder.download_button != null) {
-			viewHolder.download_button.setVisibility(View.GONE);
-		}
+		viewHolder.download_button.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.VISIBLE);
 		viewHolder.messageBody.setText(text);
@@ -311,10 +315,9 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 	}
 
 	private void displayDecryptionFailed(ViewHolder viewHolder, boolean darkBackground) {
-		if (viewHolder.download_button != null) {
-			viewHolder.download_button.setVisibility(View.GONE);
-		}
+		viewHolder.download_button.setVisibility(View.GONE);
 		viewHolder.image.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.VISIBLE);
 		viewHolder.messageBody.setText(getContext().getString(
 				R.string.decryption_failed));
@@ -324,9 +327,8 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 	}
 
 	private void displayEmojiMessage(final ViewHolder viewHolder, final String body) {
-		if (viewHolder.download_button != null) {
-			viewHolder.download_button.setVisibility(View.GONE);
-		}
+		viewHolder.download_button.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.VISIBLE);
 		viewHolder.messageBody.setIncludeFontPadding(false);
@@ -406,10 +408,9 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 	}
 
 	private void displayTextMessage(final ViewHolder viewHolder, final Message message, boolean darkBackground, int type) {
-		if (viewHolder.download_button != null) {
-			viewHolder.download_button.setVisibility(View.GONE);
-		}
+		viewHolder.download_button.setVisibility(View.GONE);
 		viewHolder.image.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.VISIBLE);
 		viewHolder.messageBody.setIncludeFontPadding(true);
 		if (message.getBody() != null) {
@@ -492,10 +493,10 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 		viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
 	}
 
-	private void displayDownloadableMessage(ViewHolder viewHolder,
-			final Message message, String text) {
+	private void displayDownloadableMessage(ViewHolder viewHolder, final Message message, String text) {
 		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.download_button.setVisibility(View.VISIBLE);
 		viewHolder.download_button.setText(text);
 		viewHolder.download_button.setOnClickListener(new OnClickListener() {
@@ -510,6 +511,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 	private void displayOpenableMessage(ViewHolder viewHolder,final Message message) {
 		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.download_button.setVisibility(View.VISIBLE);
 		viewHolder.download_button.setText(activity.getString(R.string.open_x_file, UIHelper.getFileDescriptionString(activity, message)));
 		viewHolder.download_button.setOnClickListener(new OnClickListener() {
@@ -524,6 +526,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 	private void displayLocationMessage(ViewHolder viewHolder, final Message message) {
 		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.download_button.setVisibility(View.VISIBLE);
 		viewHolder.download_button.setText(R.string.show_location);
 		viewHolder.download_button.setOnClickListener(new OnClickListener() {
@@ -535,12 +538,21 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 		});
 	}
 
-	private void displayImageMessage(ViewHolder viewHolder,
-			final Message message) {
-		if (viewHolder.download_button != null) {
-			viewHolder.download_button.setVisibility(View.GONE);
-		}
+	private void displayAudioMessage(ViewHolder viewHolder, Message message, boolean darkBackground) {
+		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.GONE);
+		viewHolder.download_button.setVisibility(View.GONE);
+		final RelativeLayout audioPlayer = viewHolder.audioPlayer;
+		audioPlayer.setVisibility(View.VISIBLE);
+		AudioPlayer.ViewHolder.get(audioPlayer).setDarkBackground(darkBackground);
+		this.audioPlayer.init(audioPlayer, message);
+	}
+
+
+	private void displayImageMessage(ViewHolder viewHolder, final Message message) {
+		viewHolder.download_button.setVisibility(View.GONE);
+		viewHolder.messageBody.setVisibility(View.GONE);
+		viewHolder.audioPlayer.setVisibility(View.GONE);
 		viewHolder.image.setVisibility(View.VISIBLE);
 		FileParams params = message.getFileParams();
 		double target = metrics.density * 288;
@@ -627,6 +639,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 						.findViewById(R.id.message_time);
 					viewHolder.indicatorReceived = (ImageView) view
 						.findViewById(R.id.indicator_received);
+					viewHolder.audioPlayer = (RelativeLayout) view.findViewById(R.id.audio_player);
 					break;
 				case RECEIVED:
 					view = activity.getLayoutInflater().inflate(
@@ -649,6 +662,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 					viewHolder.indicatorReceived = (ImageView) view
 						.findViewById(R.id.indicator_received);
 					viewHolder.encryption = (TextView) view.findViewById(R.id.message_encryption);
+					viewHolder.audioPlayer = (RelativeLayout) view.findViewById(R.id.audio_player);
 					break;
 				case STATUS:
 					view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false);
@@ -762,8 +776,10 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 		} else if (message.getType() == Message.TYPE_IMAGE && message.getEncryption() != Message.ENCRYPTION_PGP && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
 			displayImageMessage(viewHolder, message);
 		} else if (message.getType() == Message.TYPE_FILE && message.getEncryption() != Message.ENCRYPTION_PGP && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
-			if (message.getFileParams().width > 0) {
-				displayImageMessage(viewHolder,message);
+			if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
+				displayImageMessage(viewHolder, message);
+			} else if (message.getFileParams().runtime > 0) {
+				displayAudioMessage(viewHolder, message, darkBackground);
 			} else {
 				displayOpenableMessage(viewHolder, message);
 			}
@@ -877,6 +893,14 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 		}
 	}
 
+	public FileBackend getFileBackend() {
+		return activity.xmppConnectionService.getFileBackend();
+	}
+
+	public void stopAudioPlayer() {
+		audioPlayer.stop();
+	}
+
 	public interface OnQuoteListener {
 		public void onQuote(String text);
 	}
@@ -1004,6 +1028,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 		protected TextView encryption;
 		public Button load_more_messages;
 		public ImageView edit_indicator;
+		public RelativeLayout audioPlayer;
 	}
 
 	class BitmapWorkerTask extends AsyncTask<Message, Void, Bitmap> {

src/main/java/eu/siacs/conversations/ui/service/AudioPlayer.java 🔗

@@ -0,0 +1,280 @@
+package eu.siacs.conversations.ui.service;
+
+import android.content.res.ColorStateList;
+import android.media.MediaPlayer;
+import android.os.Build;
+import android.os.Handler;
+import android.support.v4.content.ContextCompat;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.RelativeLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import java.lang.ref.WeakReference;
+import java.util.Locale;
+
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.ui.adapter.MessageAdapter;
+import eu.siacs.conversations.utils.WeakReferenceSet;
+
+public class AudioPlayer implements View.OnClickListener, MediaPlayer.OnCompletionListener, SeekBar.OnSeekBarChangeListener, Runnable {
+
+	private static final int REFRESH_INTERVAL = 250;
+	private static final Object LOCK = new Object();
+	private static MediaPlayer player = null;
+	private static Message currentlyPlayingMessage = null;
+	private final MessageAdapter messageAdapter;
+	private final WeakReferenceSet<RelativeLayout> audioPlayerLayouts = new WeakReferenceSet<>();
+
+	private final Handler handler = new Handler();
+
+	public AudioPlayer(MessageAdapter adapter) {
+		this.messageAdapter = adapter;
+		synchronized (AudioPlayer.LOCK) {
+			if (AudioPlayer.player != null) {
+				AudioPlayer.player.setOnCompletionListener(this);
+			}
+		}
+	}
+
+	private static String formatTime(int ms) {
+		return String.format(Locale.ENGLISH, "%d:%02d", ms / 60000, Math.min(Math.round((ms % 60000) / 1000f), 59));
+	}
+
+	public void init(RelativeLayout audioPlayer, Message message) {
+		synchronized (AudioPlayer.LOCK) {
+			audioPlayer.setTag(message);
+			if (init(ViewHolder.get(audioPlayer), message)) {
+				this.audioPlayerLayouts.addWeakReferenceTo(audioPlayer);
+				this.stopRefresher(true);
+			} else {
+				this.audioPlayerLayouts.removeWeakReferenceTo(audioPlayer);
+			}
+		}
+	}
+
+	private boolean init(ViewHolder viewHolder, Message message) {
+		viewHolder.runtime.setTextColor(this.messageAdapter.getMessageTextColor(viewHolder.darkBackground, false));
+		viewHolder.progress.setOnSeekBarChangeListener(this);
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+			ColorStateList color = ContextCompat.getColorStateList(messageAdapter.getContext(), viewHolder.darkBackground ? R.color.white70 : R.color.bubble);
+			viewHolder.progress.setThumbTintList(color);
+			viewHolder.progress.setProgressTintList(color);
+		}
+		viewHolder.playPause.setAlpha(viewHolder.darkBackground ? 0.7f : 0.57f);
+		viewHolder.playPause.setOnClickListener(this);
+		if (message == currentlyPlayingMessage) {
+			if (AudioPlayer.player != null && AudioPlayer.player.isPlaying()) {
+				viewHolder.playPause.setImageResource(viewHolder.darkBackground ? R.drawable.ic_pause_white_36dp : R.drawable.ic_pause_black_36dp);
+				viewHolder.progress.setEnabled(true);
+			} else {
+				viewHolder.playPause.setImageResource(viewHolder.darkBackground ? R.drawable.ic_play_arrow_white_36dp : R.drawable.ic_play_arrow_black_36dp);
+				viewHolder.progress.setEnabled(false);
+			}
+			return true;
+		} else {
+			viewHolder.playPause.setImageResource(viewHolder.darkBackground ? R.drawable.ic_play_arrow_white_36dp : R.drawable.ic_play_arrow_black_36dp);
+			viewHolder.runtime.setText(formatTime(message.getFileParams().runtime));
+			viewHolder.progress.setProgress(0);
+			viewHolder.progress.setEnabled(false);
+			return false;
+		}
+	}
+
+	@Override
+	public synchronized void onClick(View v) {
+		if (v.getId() == R.id.play_pause) {
+			synchronized (LOCK) {
+				startStop((ImageButton) v);
+			}
+		}
+	}
+
+	private void startStop(ImageButton playPause) {
+		final RelativeLayout audioPlayer = (RelativeLayout) playPause.getParent();
+		final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
+		final Message message = (Message) audioPlayer.getTag();
+		if (startStop(viewHolder, message)) {
+			this.audioPlayerLayouts.clear();
+			this.audioPlayerLayouts.addWeakReferenceTo(audioPlayer);
+			stopRefresher(true);
+		}
+	}
+
+	private boolean playPauseCurrent(ViewHolder viewHolder) {
+		viewHolder.playPause.setAlpha(viewHolder.darkBackground ? 0.7f : 0.57f);
+		if (player.isPlaying()) {
+			viewHolder.progress.setEnabled(false);
+			player.pause();
+			viewHolder.playPause.setImageResource(viewHolder.darkBackground ? R.drawable.ic_play_arrow_white_36dp : R.drawable.ic_play_arrow_black_36dp);
+		} else {
+			viewHolder.progress.setEnabled(true);
+			player.start();
+			this.stopRefresher(true);
+			viewHolder.playPause.setImageResource(viewHolder.darkBackground ? R.drawable.ic_pause_white_36dp : R.drawable.ic_pause_black_36dp);
+		}
+		return false;
+	}
+
+	private boolean play(ViewHolder viewHolder, Message message) {
+		AudioPlayer.player = new MediaPlayer();
+		try {
+			AudioPlayer.currentlyPlayingMessage = message;
+			AudioPlayer.player.setDataSource(messageAdapter.getFileBackend().getFile(message).getAbsolutePath());
+			AudioPlayer.player.setOnCompletionListener(this);
+			AudioPlayer.player.prepare();
+			AudioPlayer.player.start();
+			viewHolder.progress.setEnabled(true);
+			viewHolder.playPause.setImageResource(viewHolder.darkBackground ? R.drawable.ic_pause_white_36dp : R.drawable.ic_pause_black_36dp);
+			return true;
+		} catch (Exception e) {
+			AudioPlayer.currentlyPlayingMessage = null;
+			return false;
+		}
+	}
+
+	private boolean startStop(ViewHolder viewHolder, Message message) {
+		if (message == currentlyPlayingMessage && player != null) {
+			return playPauseCurrent(viewHolder);
+		}
+		if (AudioPlayer.player != null) {
+			stopCurrent();
+		}
+		return play(viewHolder, message);
+	}
+
+	private void stopCurrent() {
+		if (AudioPlayer.player.isPlaying()) {
+			AudioPlayer.player.stop();
+		}
+		AudioPlayer.player.release();
+		AudioPlayer.player = null;
+		resetPlayerUi();
+	}
+
+	private void resetPlayerUi() {
+		for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
+			resetPlayerUi(audioPlayer.get());
+		}
+	}
+
+	private void resetPlayerUi(RelativeLayout audioPlayer) {
+		if (audioPlayer == null) {
+			return;
+		}
+		final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
+		final Message message = (Message) audioPlayer.getTag();
+		viewHolder.playPause.setImageResource(viewHolder.darkBackground ? R.drawable.ic_play_arrow_white_36dp : R.drawable.ic_play_arrow_black_36dp);
+		if (message != null) {
+			viewHolder.runtime.setText(formatTime(message.getFileParams().runtime));
+		}
+		viewHolder.progress.setProgress(0);
+		viewHolder.progress.setEnabled(false);
+	}
+
+	@Override
+	public void onCompletion(MediaPlayer mediaPlayer) {
+		synchronized (AudioPlayer.LOCK) {
+			this.stopRefresher(false);
+			if (AudioPlayer.player == mediaPlayer) {
+				AudioPlayer.currentlyPlayingMessage = null;
+				AudioPlayer.player = null;
+			}
+			mediaPlayer.release();
+			resetPlayerUi();
+		}
+	}
+
+	@Override
+	public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+		synchronized (AudioPlayer.LOCK) {
+			final RelativeLayout audioPlayer = (RelativeLayout) seekBar.getParent();
+			final Message message = (Message) audioPlayer.getTag();
+			if (fromUser && message == AudioPlayer.currentlyPlayingMessage) {
+				float percent = progress / 100f;
+				int duration = AudioPlayer.player.getDuration();
+				int seekTo = Math.round(duration * percent);
+				AudioPlayer.player.seekTo(seekTo);
+			}
+		}
+	}
+
+	@Override
+	public void onStartTrackingTouch(SeekBar seekBar) {
+
+	}
+
+	@Override
+	public void onStopTrackingTouch(SeekBar seekBar) {
+
+	}
+
+	public void stop() {
+		synchronized (AudioPlayer.LOCK) {
+			stopRefresher(false);
+			if (AudioPlayer.player != null) {
+				stopCurrent();
+			}
+			AudioPlayer.currentlyPlayingMessage = null;
+		}
+	}
+
+	private void stopRefresher(boolean runOnceMore) {
+		this.handler.removeCallbacks(this);
+		if (runOnceMore) {
+			this.handler.post(this);
+		}
+	}
+
+	@Override
+	public void run() {
+		synchronized (AudioPlayer.LOCK) {
+			if (AudioPlayer.player != null) {
+				boolean renew = false;
+				final int current = player.getCurrentPosition();
+				final int duration = player.getDuration();
+				for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
+					renew |= refreshAudioPlayer(audioPlayer.get(), current, duration);
+				}
+				if (renew && AudioPlayer.player.isPlaying()) {
+					handler.postDelayed(this, REFRESH_INTERVAL);
+				}
+			}
+		}
+	}
+
+	private boolean refreshAudioPlayer(RelativeLayout audioPlayer, int current, int duration) {
+		if (audioPlayer == null || audioPlayer.getVisibility() != View.VISIBLE) {
+			return false;
+		}
+		final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
+		viewHolder.progress.setProgress(current * 100 / duration);
+		viewHolder.runtime.setText(formatTime(current) + " / " + formatTime(duration));
+		return true;
+	}
+
+	public static class ViewHolder {
+		private TextView runtime;
+		private SeekBar progress;
+		private ImageButton playPause;
+		private boolean darkBackground = false;
+
+		public static ViewHolder get(RelativeLayout audioPlayer) {
+			ViewHolder viewHolder = (ViewHolder) audioPlayer.getTag(R.id.TAG_AUDIO_PLAYER_VIEW_HOLDER);
+			if (viewHolder == null) {
+				viewHolder = new ViewHolder();
+				viewHolder.runtime = (TextView) audioPlayer.findViewById(R.id.runtime);
+				viewHolder.progress = (SeekBar) audioPlayer.findViewById(R.id.progress);
+				viewHolder.playPause = (ImageButton) audioPlayer.findViewById(R.id.play_pause);
+				audioPlayer.setTag(R.id.TAG_AUDIO_PLAYER_VIEW_HOLDER, viewHolder);
+			}
+			return viewHolder;
+		}
+
+		public void setDarkBackground(boolean darkBackground) {
+			this.darkBackground = darkBackground;
+		}
+	}
+}

src/main/java/eu/siacs/conversations/utils/WeakReferenceSet.java 🔗

@@ -0,0 +1,26 @@
+package eu.siacs.conversations.utils;
+
+import java.lang.ref.WeakReference;
+import java.util.HashSet;
+import java.util.Iterator;
+
+public class WeakReferenceSet<T> extends HashSet<WeakReference<T>> {
+
+	public void removeWeakReferenceTo(T reference) {
+		for (Iterator<WeakReference<T>> iterator = iterator(); iterator.hasNext(); ) {
+			if (reference == iterator.next().get()) {
+				iterator.remove();
+			}
+		}
+	}
+
+
+	public void addWeakReferenceTo(T reference) {
+		for (WeakReference<T> weakReference : this) {
+			if (reference == weakReference.get()) {
+				return;
+			}
+		}
+		this.add(new WeakReference<>(reference));
+	}
+}

src/main/res/layout/message_content.xml 🔗

@@ -30,4 +30,41 @@
         android:layout_height="wrap_content"
         android:longClickable="true"
         android:visibility="gone"/>
+
+    <RelativeLayout
+        android:id="@+id/audio_player"
+        android:layout_width="288dp"
+        android:layout_height="wrap_content"
+        android:visibility="gone"
+        >
+
+        <ImageButton
+            android:id="@+id/play_pause"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_centerVertical="true"
+            android:alpha="?attr/icon_alpha"
+            android:background="?android:selectableItemBackground"/>
+
+        <TextView
+            android:id="@+id/runtime"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_alignParentEnd="true"
+            android:layout_alignParentRight="true"
+            android:paddingBottom="16dp"
+            android:paddingRight="16dp"
+            android:textColor="?attr/color_text_secondary"
+            android:textSize="?attr/TextSizeInfo"/>
+
+        <SeekBar
+            android:id="@+id/progress"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_below="@+id/runtime"
+            android:layout_centerVertical="true"
+            android:layout_toRightOf="@+id/play_pause"
+            android:progress="100"/>
+    </RelativeLayout>
+
 </merge>

src/main/res/values/ids.xml 🔗

@@ -3,4 +3,5 @@
     <item type="id" name="TAG_ACCOUNT"/>
     <item type="id" name="TAG_FINGERPRINT"/>
     <item type="id" name="TAG_FINGERPRINT_STATUS"/>
+    <item type="id" name="TAG_AUDIO_PLAYER_VIEW_HOLDER"/>
 </resources>