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

@ -10,7 +10,7 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 31
versionCode 3
versionCode 4
versionName "0.1"
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"/>

View File

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

View File

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