diff --git a/mastodon/build.gradle b/mastodon/build.gradle
index 8e4d9ba6..220bfbda 100644
--- a/mastodon/build.gradle
+++ b/mastodon/build.gradle
@@ -10,7 +10,7 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 31
- versionCode 3
+ versionCode 4
versionName "0.1"
}
diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml
index d6ef7361..686077fd 100644
--- a/mastodon/src/main/AndroidManifest.xml
+++ b/mastodon/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
package="org.joinmastodon.android">
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java
new file mode 100644
index 00000000..60ca2f80
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java
@@ -0,0 +1,338 @@
+package org.joinmastodon.android;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ServiceInfo;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.media.AudioManager;
+import android.media.MediaMetadata;
+import android.media.MediaPlayer;
+import android.media.session.MediaSession;
+import android.media.session.PlaybackState;
+import android.net.Uri;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.Log;
+
+import org.joinmastodon.android.model.Attachment;
+import org.joinmastodon.android.model.Status;
+import org.joinmastodon.android.ui.text.HtmlParser;
+import org.parceler.Parcels;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+
+import androidx.annotation.Nullable;
+import me.grishka.appkit.imageloader.ImageCache;
+import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
+
+public class AudioPlayerService extends Service{
+ private static final int NOTIFICATION_SERVICE=1;
+ private static final String TAG="AudioPlayerService";
+ private static final String ACTION_PLAY_PAUSE="org.joinmastodon.android.AUDIO_PLAY_PAUSE";
+ private static final String ACTION_STOP="org.joinmastodon.android.AUDIO_STOP";
+
+ private static AudioPlayerService instance;
+
+ private Status status;
+ private Attachment attachment;
+ private NotificationManager nm;
+ private MediaSession session;
+ private MediaPlayer player;
+ private boolean playerReady;
+ private Bitmap statusAvatar;
+ private static HashSet callbacks=new HashSet<>();
+ private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener=this::onAudioFocusChanged;
+ private boolean resumeAfterAudioFocusGain;
+
+ private BroadcastReceiver receiver=new BroadcastReceiver(){
+ @Override
+ public void onReceive(Context context, Intent intent){
+ if(AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())){
+ pause(false);
+ }else if(ACTION_PLAY_PAUSE.equals(intent.getAction())){
+ if(!playerReady)
+ return;
+ if(player.isPlaying())
+ pause(false);
+ else
+ play();
+ }else if(ACTION_STOP.equals(intent.getAction())){
+ stopSelf();
+ }
+ }
+ };
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent){
+ return null;
+ }
+
+ @Override
+ public void onCreate(){
+ super.onCreate();
+ nm=getSystemService(NotificationManager.class);
+// registerReceiver(receiver, new IntentFilter(Intent.ACTION_MEDIA_BUTTON));
+ registerReceiver(receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
+ registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE));
+ registerReceiver(receiver, new IntentFilter(ACTION_STOP));
+ instance=this;
+ }
+
+ @Override
+ public void onDestroy(){
+ instance=null;
+ unregisterReceiver(receiver);
+ if(player!=null){
+ player.release();
+ }
+ nm.cancel(NOTIFICATION_SERVICE);
+ for(Callback cb:callbacks)
+ cb.onPlaybackStopped(attachment.id);
+ getSystemService(AudioManager.class).abandonAudioFocus(audioFocusChangeListener);
+ super.onDestroy();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId){
+ if(player!=null){
+ player.release();
+ player=null;
+ playerReady=false;
+ }
+ if(attachment!=null){
+ for(Callback cb:callbacks)
+ cb.onPlaybackStopped(attachment.id);
+ }
+
+ status=Parcels.unwrap(intent.getParcelableExtra("status"));
+ attachment=Parcels.unwrap(intent.getParcelableExtra("attachment"));
+
+ session=new MediaSession(this, "audioPlayer");
+ session.setPlaybackState(new PlaybackState.Builder()
+ .setState(PlaybackState.STATE_BUFFERING, PlaybackState.PLAYBACK_POSITION_UNKNOWN, 1f)
+ .setActions(PlaybackState.ACTION_STOP)
+ .build());
+ MediaMetadata metadata=new MediaMetadata.Builder()
+ .putLong(MediaMetadata.METADATA_KEY_DURATION, (long)(attachment.getDuration()*1000))
+ .build();
+ session.setMetadata(metadata);
+ session.setActive(true);
+ session.setCallback(new MediaSession.Callback(){
+ @Override
+ public void onPlay(){
+ play();
+ }
+
+ @Override
+ public void onPause(){
+ pause(false);
+ }
+
+ @Override
+ public void onStop(){
+ stopSelf();
+ }
+
+ @Override
+ public void onSeekTo(long pos){
+ seekTo((int)pos);
+ }
+ });
+
+ Drawable d=ImageCache.getInstance(this).getFromTop(new UrlImageLoaderRequest(status.account.avatar));
+ if(d instanceof BitmapDrawable){
+ statusAvatar=((BitmapDrawable) d).getBitmap();
+ }else{
+ statusAvatar=Bitmap.createBitmap(d.getIntrinsicWidth(), d.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ d.setBounds(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight());
+ d.draw(new Canvas(statusAvatar));
+ }
+
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){
+ NotificationChannel chan=new NotificationChannel("audioPlayer", getString(R.string.notification_channel_audio_player), NotificationManager.IMPORTANCE_LOW);
+ nm.createNotificationChannel(chan);
+ }
+
+ updateNotification(false, false);
+ getSystemService(AudioManager.class).requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
+
+ player=new MediaPlayer();
+ player.setOnPreparedListener(this::onPlayerPrepared);
+ player.setOnErrorListener(this::onPlayerError);
+ player.setOnCompletionListener(this::onPlayerCompletion);
+ player.setOnSeekCompleteListener(this::onPlayerSeekCompleted);
+ try{
+ player.setDataSource(this, Uri.parse(attachment.url));
+ player.prepareAsync();
+ }catch(IOException x){
+ Log.w(TAG, "onStartCommand: error starting media player", x);
+ }
+
+ return START_NOT_STICKY;
+ }
+
+ private void onPlayerPrepared(MediaPlayer mp){
+ playerReady=true;
+ player.start();
+ updateSessionState(false);
+ }
+
+ private boolean onPlayerError(MediaPlayer mp, int error, int extra){
+ Log.e(TAG, "onPlayerError() called with: mp = ["+mp+"], error = ["+error+"], extra = ["+extra+"]");
+ return false;
+ }
+
+ private void onPlayerSeekCompleted(MediaPlayer mp){
+ updateSessionState(false);
+ }
+
+ private void onPlayerCompletion(MediaPlayer mp){
+ stopSelf();
+ }
+
+ private void onAudioFocusChanged(int change){
+ switch(change){
+ case AudioManager.AUDIOFOCUS_LOSS -> {
+ resumeAfterAudioFocusGain=false;
+ pause(false);
+ }
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
+ resumeAfterAudioFocusGain=true;
+ pause(false);
+ }
+ case AudioManager.AUDIOFOCUS_GAIN -> {
+ if(resumeAfterAudioFocusGain){
+ play();
+ }else if(isPlaying()){
+ player.setVolume(1f, 1f);
+ }
+ }
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
+ if(isPlaying()){
+ player.setVolume(.3f, .3f);
+ }
+ }
+ }
+ }
+
+ private void updateSessionState(boolean removeNotification){
+ session.setPlaybackState(new PlaybackState.Builder()
+ .setState(player.isPlaying() ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED, player.getCurrentPosition(), 1f)
+ .setActions(PlaybackState.ACTION_STOP | PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_SEEK_TO)
+ .build());
+ updateNotification(!player.isPlaying(), removeNotification);
+ for(Callback cb:callbacks)
+ cb.onPlayStateChanged(attachment.id, player.isPlaying(), player.getCurrentPosition());
+ }
+
+ private void updateNotification(boolean dismissable, boolean removeNotification){
+ Notification.Builder bldr=new Notification.Builder(this)
+ .setSmallIcon(R.drawable.ic_ntf_logo)
+ .setContentTitle(status.account.displayName)
+ .setContentText(HtmlParser.strip(status.content))
+ .setOngoing(!dismissable)
+ .setShowWhen(false)
+ .setDeleteIntent(PendingIntent.getBroadcast(this, 3, new Intent(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE));
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){
+ bldr.setChannelId("audioPlayer");
+ }
+ if(statusAvatar!=null)
+ bldr.setLargeIcon(statusAvatar);
+
+ Notification.MediaStyle style=new Notification.MediaStyle().setMediaSession(session.getSessionToken());
+
+ if(playerReady){
+ boolean isPlaying=player.isPlaying();
+ bldr.addAction(new Notification.Action.Builder(Icon.createWithResource(this, isPlaying ? R.drawable.ic_pause_24 : R.drawable.ic_play_24),
+ getString(isPlaying ? R.string.pause : R.string.play),
+ PendingIntent.getBroadcast(this, 2, new Intent(ACTION_PLAY_PAUSE), PendingIntent.FLAG_IMMUTABLE))
+ .build());
+ style.setShowActionsInCompactView(0);
+ }
+ bldr.setStyle(style);
+
+ if(dismissable){
+ stopForeground(removeNotification);
+ if(!removeNotification)
+ nm.notify(NOTIFICATION_SERVICE, bldr.build());
+ }else if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.Q){
+ startForeground(NOTIFICATION_SERVICE, bldr.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
+ }else{
+ startForeground(NOTIFICATION_SERVICE, bldr.build());
+ }
+ }
+
+ public void pause(boolean removeNotification){
+ if(player.isPlaying()){
+ player.pause();
+ updateSessionState(removeNotification);
+ }
+ }
+
+ public void play(){
+ if(playerReady && !player.isPlaying()){
+ player.start();
+ updateSessionState(false);
+ }
+ }
+
+ public void seekTo(int offset){
+ if(playerReady){
+ player.seekTo(offset);
+ updateSessionState(false);
+ }
+ }
+
+ public boolean isPlaying(){
+ return playerReady && player.isPlaying();
+ }
+
+ public int getPosition(){
+ return playerReady ? player.getCurrentPosition() : 0;
+ }
+
+ public String getAttachmentID(){
+ return attachment.id;
+ }
+
+ public static void registerCallback(Callback cb){
+ callbacks.add(cb);
+ }
+
+ public static void unregisterCallback(Callback cb){
+ callbacks.remove(cb);
+ }
+
+ public static void start(Context context, Status status, Attachment attachment){
+ Intent intent=new Intent(context, AudioPlayerService.class);
+ intent.putExtra("status", Parcels.wrap(status));
+ intent.putExtra("attachment", Parcels.wrap(attachment));
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O)
+ context.startForegroundService(intent);
+ else
+ context.startService(intent);
+ }
+
+ public static AudioPlayerService getInstance(){
+ return instance;
+ }
+
+ public interface Callback{
+ void onPlayStateChanged(String attachmentID, boolean playing, int position);
+ void onPlaybackStopped(String attachmentID);
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java
index e8f8fd6a..3827d10a 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java
@@ -24,6 +24,7 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.TileGridLayoutManager;
+import org.joinmastodon.android.ui.displayitems.AudioStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java b/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java
index ceccfc3a..9dff2c4d 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java
@@ -67,6 +67,16 @@ public class Attachment extends BaseModel{
return 0;
}
+ public double getDuration(){
+ if(meta==null)
+ return 0;
+ if(meta.duration>0)
+ return meta.duration;
+ if(meta.original!=null && meta.original.duration>0)
+ return meta.original.duration;
+ return 0;
+ }
+
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
@@ -137,6 +147,8 @@ public class Attachment extends BaseModel{
public int width;
public int height;
public double aspect;
+ public double duration;
+ public int bitrate;
@Override
public String toString(){
@@ -144,6 +156,8 @@ public class Attachment extends BaseModel{
"width="+width+
", height="+height+
", aspect="+aspect+
+ ", duration="+duration+
+ ", bitrate="+bitrate+
'}';
}
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java
new file mode 100644
index 00000000..cb039cb6
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java
@@ -0,0 +1,167 @@
+package org.joinmastodon.android.ui.displayitems;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import org.joinmastodon.android.AudioPlayerService;
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.fragments.BaseStatusListFragment;
+import org.joinmastodon.android.model.Attachment;
+import org.joinmastodon.android.model.Status;
+import org.joinmastodon.android.ui.drawables.SeekBarThumbDrawable;
+
+public class AudioStatusDisplayItem extends StatusDisplayItem{
+ public final Status status;
+ public final Attachment attachment;
+
+ public AudioStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, Attachment attachment){
+ super(parentID, parentFragment);
+ this.status=status;
+ this.attachment=attachment;
+ }
+
+ @Override
+ public Type getType(){
+ return Type.AUDIO;
+ }
+
+ public static class Holder extends StatusDisplayItem.Holder implements AudioPlayerService.Callback{
+ private final ImageButton playPauseBtn;
+ private final TextView time;
+ private final SeekBar seekBar;
+
+ private int lastKnownPosition;
+ private long lastKnownPositionTime;
+ private boolean playing;
+ private int lastRemainingSeconds=-1;
+ private boolean seekbarBeingDragged;
+
+ private Runnable positionUpdater=this::updatePosition;
+
+ public Holder(Context context, ViewGroup parent){
+ super(context, R.layout.display_item_audio, parent);
+ playPauseBtn=findViewById(R.id.play_pause_btn);
+ time=findViewById(R.id.time);
+ seekBar=findViewById(R.id.seekbar);
+ seekBar.setThumb(new SeekBarThumbDrawable(context));
+ playPauseBtn.setOnClickListener(this::onPlayPauseClick);
+ itemView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener(){
+ @Override
+ public void onViewAttachedToWindow(View v){
+ AudioPlayerService.registerCallback(Holder.this);
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v){
+ AudioPlayerService.unregisterCallback(Holder.this);
+ }
+ });
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser){
+ if(fromUser){
+ int seconds=(int)(seekBar.getProgress()/10000.0*item.attachment.getDuration());
+ time.setText(formatDuration(seconds));
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar){
+ seekbarBeingDragged=true;
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar){
+ AudioPlayerService service=AudioPlayerService.getInstance();
+ if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
+ service.seekTo((int)(seekBar.getProgress()/10000.0*item.attachment.getDuration()*1000.0));
+ }
+ seekbarBeingDragged=false;
+ if(playing)
+ itemView.postOnAnimation(positionUpdater);
+ }
+ });
+ }
+
+ @Override
+ public void onBind(AudioStatusDisplayItem item){
+ int seconds=(int)item.attachment.getDuration();
+ String duration=formatDuration(seconds);
+ time.getLayoutParams().width=(int)Math.ceil(time.getPaint().measureText("-"+duration));
+ time.setText(duration);
+ AudioPlayerService service=AudioPlayerService.getInstance();
+ if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
+ seekBar.setEnabled(true);
+ onPlayStateChanged(item.attachment.id, service.isPlaying(), service.getPosition());
+ }else{
+ seekBar.setEnabled(false);
+ }
+ }
+
+ private void onPlayPauseClick(View v){
+ AudioPlayerService service=AudioPlayerService.getInstance();
+ if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
+ if(playing)
+ service.pause(true);
+ else
+ service.play();
+ }else{
+ AudioPlayerService.start(v.getContext(), item.status, item.attachment);
+ onPlayStateChanged(item.attachment.id, true, 0);
+ seekBar.setEnabled(true);
+ }
+ }
+
+ @Override
+ public void onPlayStateChanged(String attachmentID, boolean playing, int position){
+ if(attachmentID.equals(item.attachment.id)){
+ this.lastKnownPosition=position;
+ lastKnownPositionTime=SystemClock.uptimeMillis();
+ this.playing=playing;
+ playPauseBtn.setImageResource(playing ? R.drawable.ic_fluent_pause_circle_24_filled : R.drawable.ic_fluent_play_circle_24_filled);
+ if(!playing){
+ lastRemainingSeconds=-1;
+ time.setText(formatDuration((int) item.attachment.getDuration()));
+ }else{
+ itemView.postOnAnimation(positionUpdater);
+ }
+ }
+ }
+
+ @Override
+ public void onPlaybackStopped(String attachmentID){
+ if(attachmentID.equals(item.attachment.id)){
+ playing=false;
+ playPauseBtn.setImageResource(R.drawable.ic_fluent_play_circle_24_filled);
+ seekBar.setProgress(0);
+ seekBar.setEnabled(false);
+ time.setText(formatDuration((int)item.attachment.getDuration()));
+ }
+ }
+
+ private String formatDuration(int seconds){
+ if(seconds>=3600)
+ return String.format("%d:%02d:%02d", seconds/3600, seconds%3600/60, seconds%60);
+ else
+ return String.format("%d:%02d", seconds/60, seconds%60);
+ }
+
+ private void updatePosition(){
+ if(!playing || seekbarBeingDragged)
+ return;
+ double pos=lastKnownPosition/1000.0+(SystemClock.uptimeMillis()-lastKnownPositionTime)/1000.0;
+ seekBar.setProgress((int)Math.round(pos/item.attachment.getDuration()*10000.0));
+ itemView.postOnAnimation(positionUpdater);
+ int remainingSeconds=(int)(item.attachment.getDuration()-pos);
+ if(remainingSeconds!=lastRemainingSeconds){
+ lastRemainingSeconds=remainingSeconds;
+ time.setText("-"+formatDuration(remainingSeconds));
+ }
+ }
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java
index 2ba9805b..25a8ac5c 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java
@@ -52,12 +52,12 @@ public abstract class StatusDisplayItem{
case TEXT -> new TextStatusDisplayItem.Holder(activity, parent);
case PHOTO -> new PhotoStatusDisplayItem.Holder(activity, parent);
case GIFV -> new GifVStatusDisplayItem.Holder(activity, parent);
+ case AUDIO -> new AudioStatusDisplayItem.Holder(activity, parent);
case VIDEO -> new VideoStatusDisplayItem.Holder(activity, parent);
case POLL_OPTION -> new PollOptionStatusDisplayItem.Holder(activity, parent);
case POLL_FOOTER -> new PollFooterStatusDisplayItem.Holder(activity, parent);
case CARD -> new LinkCardStatusDisplayItem.Holder(activity, parent);
case FOOTER -> new FooterStatusDisplayItem.Holder(activity, parent);
- default -> throw new UnsupportedOperationException();
};
}
@@ -94,6 +94,11 @@ public abstract class StatusDisplayItem{
photoIndex++;
}
}
+ for(Attachment att:statusForContent.mediaAttachments){
+ if(att.type==Attachment.Type.AUDIO){
+ items.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att));
+ }
+ }
if(statusForContent.poll!=null){
buildPollItems(parentID, fragment, statusForContent.poll, items);
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/SeekBarThumbDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/SeekBarThumbDrawable.java
new file mode 100644
index 00000000..9a7c0353
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/SeekBarThumbDrawable.java
@@ -0,0 +1,76 @@
+package org.joinmastodon.android.ui.drawables;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Drawable;
+
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.ui.utils.UiUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import me.grishka.appkit.utils.V;
+
+public class SeekBarThumbDrawable extends Drawable{
+ private Bitmap shadow1, shadow2;
+ private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
+ private Context context;
+
+ public SeekBarThumbDrawable(Context context){
+ this.context=context;
+ shadow1=Bitmap.createBitmap(V.dp(24), V.dp(24), Bitmap.Config.ALPHA_8);
+ shadow2=Bitmap.createBitmap(V.dp(24), V.dp(24), Bitmap.Config.ALPHA_8);
+ Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setColor(0xFF000000);
+ paint.setShadowLayer(V.dp(2), 0, V.dp(1), 0xFF000000);
+ new Canvas(shadow1).drawCircle(V.dp(12), V.dp(12), V.dp(9), paint);
+ paint.setShadowLayer(V.dp(3), 0, V.dp(1), 0xFF000000);
+ new Canvas(shadow2).drawCircle(V.dp(12), V.dp(12), V.dp(9), paint);
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas){
+ float centerX=getBounds().centerX();
+ float centerY=getBounds().centerY();
+ paint.setStyle(Paint.Style.FILL);
+ paint.setColor(0x4d000000);
+ canvas.drawBitmap(shadow1, centerX-shadow1.getWidth()/2f, centerY-shadow1.getHeight()/2f, paint);
+ paint.setColor(0x26000000);
+ canvas.drawBitmap(shadow2, centerX-shadow2.getWidth()/2f, centerY-shadow2.getHeight()/2f, paint);
+ paint.setColor(UiUtils.getThemeColor(context, R.attr.colorButtonText));
+ canvas.drawCircle(centerX, centerY, V.dp(7), paint);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setColor(UiUtils.getThemeColor(context, R.attr.colorAccentLight));
+ paint.setStrokeWidth(V.dp(4));
+ canvas.drawCircle(centerX, centerY, V.dp(7), paint);
+ }
+
+ @Override
+ public void setAlpha(int alpha){
+
+ }
+
+ @Override
+ public void setColorFilter(@Nullable ColorFilter colorFilter){
+
+ }
+
+ @Override
+ public int getOpacity(){
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public int getIntrinsicWidth(){
+ return V.dp(24);
+ }
+
+ @Override
+ public int getIntrinsicHeight(){
+ return V.dp(24);
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java
index 541cc66b..21a324c9 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java
@@ -11,6 +11,8 @@ import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
+import org.jsoup.safety.Cleaner;
+import org.jsoup.safety.Safelist;
import org.jsoup.select.NodeVisitor;
import java.util.ArrayList;
@@ -144,4 +146,8 @@ public class HtmlParser{
view.setText(parseCustomEmoji(text, emojis));
UiUtils.loadCustomEmojiInTextView(view);
}
+
+ public static String strip(String html){
+ return Jsoup.clean(html, Safelist.none());
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java
index 0173d089..f9c26425 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java
@@ -5,7 +5,6 @@ import android.app.Activity;
import android.content.Context;
import android.content.res.TypedArray;
import android.database.Cursor;
-import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
@@ -13,7 +12,6 @@ import android.os.Handler;
import android.os.Looper;
import android.provider.OpenableColumns;
import android.text.Spanned;
-import android.util.Log;
import android.view.View;
import android.widget.TextView;
@@ -40,7 +38,6 @@ import java.util.function.Consumer;
import java.util.stream.Collectors;
import androidx.annotation.AttrRes;
-import androidx.annotation.ColorRes;
import androidx.annotation.StringRes;
import androidx.browser.customtabs.CustomTabsIntent;
import me.grishka.appkit.Nav;
@@ -58,6 +55,7 @@ public class UiUtils{
public static void launchWebBrowser(Context context, String url){
// TODO setting for custom tabs
new CustomTabsIntent.Builder()
+ .setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(url));
}
diff --git a/mastodon/src/main/res/drawable/ic_fluent_pause_circle_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_pause_circle_24_filled.xml
new file mode 100644
index 00000000..df17cb79
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_fluent_pause_circle_24_filled.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_fluent_play_circle_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_play_circle_24_filled.xml
new file mode 100644
index 00000000..237a4d54
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_fluent_play_circle_24_filled.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_ntf_logo.xml b/mastodon/src/main/res/drawable/ic_ntf_logo.xml
new file mode 100644
index 00000000..eeefbbd1
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_ntf_logo.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_pause_24.xml b/mastodon/src/main/res/drawable/ic_pause_24.xml
new file mode 100644
index 00000000..9c312c98
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_pause_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_play_24.xml b/mastodon/src/main/res/drawable/ic_play_24.xml
new file mode 100644
index 00000000..8986c1d0
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_play_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/seekbar_progress.xml b/mastodon/src/main/res/drawable/seekbar_progress.xml
new file mode 100644
index 00000000..1f5f64f0
--- /dev/null
+++ b/mastodon/src/main/res/drawable/seekbar_progress.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/display_item_audio.xml b/mastodon/src/main/res/layout/display_item_audio.xml
new file mode 100644
index 00000000..11e370b9
--- /dev/null
+++ b/mastodon/src/main/res/layout/display_item_audio.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/attrs.xml b/mastodon/src/main/res/values/attrs.xml
index 31fba285..9c490673 100644
--- a/mastodon/src/main/res/values/attrs.xml
+++ b/mastodon/src/main/res/values/attrs.xml
@@ -9,6 +9,8 @@
+
+
diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml
index d8670fe2..05842ff7 100644
--- a/mastodon/src/main/res/values/strings.xml
+++ b/mastodon/src/main/res/values/strings.xml
@@ -128,4 +128,7 @@
Delete Post
Are you sure you want to delete this post?
Deleting…
+ Audio playback
+ Play
+ Pause
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/styles.xml b/mastodon/src/main/res/values/styles.xml
index f5965ec9..e762dc77 100644
--- a/mastodon/src/main/res/values/styles.xml
+++ b/mastodon/src/main/res/values/styles.xml
@@ -27,6 +27,9 @@
- @style/Theme.Mastodon.Dialog.Alert
- @color/primary_500
- @color/gray_300
+ - @color/primary_600
+
+ - @drawable/bg_button_primary_dark_on_light
- true
- true
@@ -59,6 +62,9 @@
- @style/Theme.Mastodon.Dialog.Alert.Dark
- @color/primary_700
- @color/gray_600
+ - @color/primary_600
+
+ - @drawable/bg_button_primary_light_on_dark
- false
- false