Audio player
This commit is contained in:
parent
658415fd89
commit
4e59930d15
|
@ -10,7 +10,7 @@ android {
|
|||
applicationId "org.joinmastodon.android"
|
||||
minSdk 23
|
||||
targetSdk 31
|
||||
versionCode 3
|
||||
versionCode 4
|
||||
versionName "0.1"
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
package="org.joinmastodon.android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
||||
<application
|
||||
android:name=".MastodonApp"
|
||||
|
@ -27,6 +28,8 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -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<Callback> 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AudioStatusDisplayItem> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1.5 6.25v7.5c0 0.414-0.336 0.75-0.75 0.75S9 16.164 9 15.75v-7.5C9 7.836 9.336 7.5 9.75 7.5s0.75 0.336 0.75 0.75zm4.5 0v7.5c0 0.414-0.336 0.75-0.75 0.75s-0.75-0.336-0.75-0.75v-7.5c0-0.414 0.336-0.75 0.75-0.75S15 7.836 15 8.25z" android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="25dp" android:height="24dp" android:viewportWidth="25" android:viewportHeight="24">
|
||||
<path android:pathData="M2.416 12c0-5.523 4.477-10 10-10s10 4.477 10 10-4.477 10-10 10-10-4.477-10-10zm8.856-3.845c-0.834-0.461-1.856 0.141-1.856 1.094v5.503c0 0.952 1.022 1.554 1.856 1.093l5.757-3.189c0.239-0.132 0.387-0.383 0.387-0.656s-0.148-0.524-0.387-0.656l-5.757-3.189z" android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M16.7096,17.7682C19.4819,17.4391 21.8955,15.7408 22.199,14.1888C22.6769,11.7442 22.6376,8.2231 22.6376,8.2231C22.6376,3.4504 19.4929,2.0516 19.4929,2.0516C17.9073,1.3274 15.1846,1.023 12.356,1H12.2865C9.4579,1.023 6.7369,1.3274 5.1513,2.0516C5.1513,2.0516 2.0066,3.4504 2.0066,8.2231C2.0066,8.5125 2.0051,8.8169 2.0035,9.1339C1.9991,10.0135 1.9943,10.9896 2.0199,12.0083C2.1341,16.6755 2.8805,21.2752 7.2202,22.4175C9.2213,22.944 10.9392,23.0542 12.323,22.9785C14.832,22.8403 16.2406,22.0883 16.2406,22.0883L16.1577,20.2779C16.1577,20.2779 14.3648,20.8402 12.3511,20.7717C10.356,20.7037 8.2496,20.5577 7.9269,18.1221C7.8972,17.9082 7.8823,17.6794 7.8823,17.4391C7.8823,17.4391 9.8408,17.9152 12.323,18.0283C13.8407,18.0974 15.2639,17.9399 16.7096,17.7682ZM18.8747,14.3719V8.5932C18.8747,7.4121 18.5723,6.4736 17.9648,5.7792C17.3382,5.0849 16.518,4.729 15.4997,4.729C14.3212,4.729 13.4291,5.1792 12.8392,6.0799L12.2657,7.0359L11.692,6.0799C11.1023,5.1792 10.21,4.729 9.0316,4.729C8.0134,4.729 7.193,5.0849 6.5664,5.7792C5.9589,6.4736 5.6565,7.4121 5.6565,8.5932V14.3719H7.959V8.763C7.959,7.5805 8.4594,6.9806 9.4602,6.9806C10.5665,6.9806 11.1211,7.6925 11.1211,9.1001V12.1701H13.4101V9.1001C13.4101,7.6925 13.9647,6.9806 15.071,6.9806C16.0718,6.9806 16.5722,7.5805 16.5722,8.763V14.3719H18.8747Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M8,5v14l11,-7z"/>
|
||||
</vector>
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:height="4dp" android:gravity="center_vertical">
|
||||
<shape>
|
||||
<solid android:color="?colorPollVoted"/>
|
||||
<corners android:radius="2dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item android:id="@android:id/progress" android:height="4dp" android:gravity="center_vertical">
|
||||
<clip>
|
||||
<shape>
|
||||
<solid android:color="?colorAccentLight"/>
|
||||
<corners android:radius="2dp"/>
|
||||
</shape>
|
||||
</clip>
|
||||
</item>
|
||||
</layer-list>
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-8dp"
|
||||
android:layout_marginBottom="-8dp"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp"
|
||||
android:background="?buttonBackground"
|
||||
android:outlineProvider="background"
|
||||
android:elevation="2dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/play_pause_btn"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:background="?android:selectableItemBackgroundBorderless"
|
||||
android:tint="?colorButtonText"
|
||||
android:src="@drawable/ic_fluent_play_circle_24_filled"/>
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:progressDrawable="@drawable/seekbar_progress"
|
||||
android:max="10000"
|
||||
android:splitTrack="false"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/m3_label_medium"
|
||||
android:textColor="?colorButtonText"
|
||||
android:gravity="end"
|
||||
tools:text="1:23"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
|
@ -9,6 +9,8 @@
|
|||
<attr name="colorPollVoted" format="color"/>
|
||||
<attr name="colorWindowBackground" format="color"/>
|
||||
<attr name="secondaryButtonStyle" format="reference"/>
|
||||
<attr name="buttonBackground" format="reference"/>
|
||||
<attr name="colorAccentLight" format="color"/>
|
||||
|
||||
<declare-styleable name="MaxWidthFrameLayout">
|
||||
<attr name="android:maxWidth" format="dimension"/>
|
||||
|
|
|
@ -128,4 +128,7 @@
|
|||
<string name="confirm_delete_title">Delete Post</string>
|
||||
<string name="confirm_delete">Are you sure you want to delete this post?</string>
|
||||
<string name="deleting">Deleting…</string>
|
||||
<string name="notification_channel_audio_player">Audio playback</string>
|
||||
<string name="play">Play</string>
|
||||
<string name="pause">Pause</string>
|
||||
</resources>
|
|
@ -27,6 +27,9 @@
|
|||
<item name="android:alertDialogTheme">@style/Theme.Mastodon.Dialog.Alert</item>
|
||||
<item name="colorPollMostVoted">@color/primary_500</item>
|
||||
<item name="colorPollVoted">@color/gray_300</item>
|
||||
<item name="colorAccentLight">@color/primary_600</item>
|
||||
|
||||
<item name="buttonBackground">@drawable/bg_button_primary_dark_on_light</item>
|
||||
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowLightNavigationBar" tools:ignore="NewApi">true</item>
|
||||
|
@ -59,6 +62,9 @@
|
|||
<item name="android:alertDialogTheme">@style/Theme.Mastodon.Dialog.Alert.Dark</item>
|
||||
<item name="colorPollMostVoted">@color/primary_700</item>
|
||||
<item name="colorPollVoted">@color/gray_600</item>
|
||||
<item name="colorAccentLight">@color/primary_600</item>
|
||||
|
||||
<item name="buttonBackground">@drawable/bg_button_primary_light_on_dark</item>
|
||||
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowLightNavigationBar" tools:ignore="NewApi">false</item>
|
||||
|
|
Loading…
Reference in New Issue