Audio player

This commit is contained in:
Grishka
2022-02-22 14:01:48 +03:00
parent 658415fd89
commit 4e59930d15
20 changed files with 728 additions and 5 deletions

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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+
'}';
}
}

View File

@@ -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));
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}