From 820f783d01ea2f91ecf55650b96f3f092ac20616 Mon Sep 17 00:00:00 2001 From: Antonio Cappiello Date: Tue, 8 Dec 2020 20:30:21 +0100 Subject: [PATCH] Preparation to music streaming - Picking from Gelli --- app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 8 +- .../glide/palette/BitmapPaletteWrapper.java | 23 + .../service/MediaButtonIntentReceiver.java | 219 ++++ .../play/service/MultiPlayer.java | 290 +++++ .../play/service/MusicService.java | 1105 ++++++++++++++++- .../notification/PlayingNotification.java | 181 +++ .../play/service/playback/Playback.java | 39 + .../play/ui/activities/MainActivity.java | 50 +- .../fragment/PlayerBottomSheetFragment.java | 4 +- .../play/util/PreferenceUtil.java | 25 +- app/src/main/res/drawable/ic_notification.png | Bin 0 -> 2911 bytes .../main/res/drawable/ic_pause_white_24dp.xml | 9 + .../res/drawable/ic_play_arrow_white_24dp.xml | 9 + .../res/drawable/ic_skip_next_white_24dp.xml | 9 + .../drawable/ic_skip_previous_white_24dp.xml | 9 + app/src/main/res/values/dimens.xml | 3 + app/src/main/res/values/strings.xml | 19 +- 18 files changed, 1921 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/play/glide/palette/BitmapPaletteWrapper.java create mode 100644 app/src/main/java/com/cappielloantonio/play/service/MediaButtonIntentReceiver.java create mode 100644 app/src/main/java/com/cappielloantonio/play/service/MultiPlayer.java create mode 100644 app/src/main/java/com/cappielloantonio/play/service/notification/PlayingNotification.java create mode 100644 app/src/main/java/com/cappielloantonio/play/service/playback/Playback.java create mode 100644 app/src/main/res/drawable/ic_notification.png create mode 100644 app/src/main/res/drawable/ic_pause_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_play_arrow_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_skip_next_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_skip_previous_white_24dp.xml diff --git a/app/build.gradle b/app/build.gradle index 9e40c569..b313c862 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,7 +30,6 @@ android { } buildFeatures { - dataBinding true viewBinding = true } } @@ -58,6 +57,8 @@ dependencies { implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.paging:paging-runtime-ktx:2.1.2' implementation "androidx.lifecycle:lifecycle-common-java8:2.2.0" + implementation 'androidx.palette:palette:1.0.0' + // Android Material implementation 'com.google.android.material:material:1.2.1' @@ -74,10 +75,11 @@ dependencies { // Glide implementation 'com.github.bumptech.glide:glide:4.11.0' + implementation 'com.github.bumptech.glide:okhttp3-integration:4.11.0' implementation "com.github.woltapp:blurhash:f41a23cc50" // Exoplayer - implementation 'com.google.android.exoplayer:exoplayer:2.12.2' + implementation 'com.google.android.exoplayer:exoplayer:2.11.4' annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' annotationProcessor "androidx.room:room-compiler:2.2.5" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba99cfae..531efac3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,9 +3,9 @@ xmlns:tools="http://schemas.android.com/tools" package="com.cappielloantonio.play"> - + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/play/glide/palette/BitmapPaletteWrapper.java b/app/src/main/java/com/cappielloantonio/play/glide/palette/BitmapPaletteWrapper.java new file mode 100644 index 00000000..e6817671 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/glide/palette/BitmapPaletteWrapper.java @@ -0,0 +1,23 @@ +package com.cappielloantonio.play.glide.palette; + +import android.graphics.Bitmap; + +import androidx.palette.graphics.Palette; + +public class BitmapPaletteWrapper { + private final Bitmap bitmap; + private final Palette palette; + + public BitmapPaletteWrapper(Bitmap bitmap, Palette palette) { + this.bitmap = bitmap; + this.palette = palette; + } + + public Bitmap getBitmap() { + return bitmap; + } + + public Palette getPalette() { + return palette; + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/service/MediaButtonIntentReceiver.java b/app/src/main/java/com/cappielloantonio/play/service/MediaButtonIntentReceiver.java new file mode 100644 index 00000000..50f610de --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/service/MediaButtonIntentReceiver.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2007 The Android Open Source Project Licensed under the Apache + * License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law + * or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// Modified for Phonograph by Karim Abou Zeid (kabouzeid). + +package com.cappielloantonio.play.service; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Message; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.util.Log; +import android.view.KeyEvent; + +import androidx.core.content.ContextCompat; + +import com.cappielloantonio.play.BuildConfig; + +/** + * Used to control headset playback. + * Single press: pause/resume + * Double press: next track + * Triple press: previous track + */ +public class MediaButtonIntentReceiver extends BroadcastReceiver { + private static final boolean DEBUG = BuildConfig.DEBUG; + public static final String TAG = MediaButtonIntentReceiver.class.getSimpleName(); + + private static final int MSG_HEADSET_DOUBLE_CLICK_TIMEOUT = 2; + + private static final int DOUBLE_CLICK = 400; + + private static WakeLock mWakeLock = null; + private static int mClickCounter = 0; + private static long mLastClickTime = 0; + + @SuppressLint("HandlerLeak") + private static Handler mHandler = new Handler() { + + @Override + public void handleMessage(final Message msg) { + switch (msg.what) { + case MSG_HEADSET_DOUBLE_CLICK_TIMEOUT: + final int clickCount = msg.arg1; + final String command; + + if (DEBUG) Log.v(TAG, "Handling headset click, count = " + clickCount); + switch (clickCount) { + case 1: + command = MusicService.ACTION_TOGGLE; + break; + case 2: + command = MusicService.ACTION_SKIP; + break; + case 3: + command = MusicService.ACTION_REWIND; + break; + default: + command = null; + break; + } + + if (command != null) { + final Context context = (Context) msg.obj; + startService(context, command); + } + + break; + } + + releaseWakeLockIfHandlerIdle(); + } + }; + + @Override + public void onReceive(final Context context, final Intent intent) { + if (DEBUG) Log.v(TAG, "Received intent: " + intent); + if (handleIntent(context, intent) && isOrderedBroadcast()) { + abortBroadcast(); + } + } + + public static boolean handleIntent(final Context context, final Intent intent) { + final String intentAction = intent.getAction(); + if (Intent.ACTION_MEDIA_BUTTON.equals(intentAction)) { + final KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); + if (event == null) { + return false; + } + + final int keycode = event.getKeyCode(); + final int action = event.getAction(); + + // fallback to system time if event time is not available + final long eventTime = event.getEventTime() != 0 + ? event.getEventTime() + : System.currentTimeMillis(); + + String command = null; + switch (keycode) { + case KeyEvent.KEYCODE_MEDIA_STOP: + command = MusicService.ACTION_STOP; + break; + case KeyEvent.KEYCODE_HEADSETHOOK: + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + command = MusicService.ACTION_TOGGLE; + break; + case KeyEvent.KEYCODE_MEDIA_NEXT: + command = MusicService.ACTION_SKIP; + break; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + command = MusicService.ACTION_REWIND; + break; + case KeyEvent.KEYCODE_MEDIA_PAUSE: + command = MusicService.ACTION_PAUSE; + break; + case KeyEvent.KEYCODE_MEDIA_PLAY: + command = MusicService.ACTION_PLAY; + break; + } + + if (command != null) { + if (action == KeyEvent.ACTION_DOWN) { + if (event.getRepeatCount() == 0) { + // Only consider the first event in a sequence, not the repeat events, + // so that we don't trigger in cases where the first event went to + // a different app (e.g. when the user ends a phone call by + // long pressing the headset button) + + // The service may or may not be running, but we need to send it + // a command. + if (keycode == KeyEvent.KEYCODE_HEADSETHOOK || keycode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) { + if (eventTime - mLastClickTime >= DOUBLE_CLICK) { + mClickCounter = 0; + } + + mClickCounter++; + if (DEBUG) Log.v(TAG, "Got headset click, count = " + mClickCounter); + mHandler.removeMessages(MSG_HEADSET_DOUBLE_CLICK_TIMEOUT); + + Message msg = mHandler.obtainMessage( + MSG_HEADSET_DOUBLE_CLICK_TIMEOUT, mClickCounter, 0, context); + + long delay = mClickCounter < 3 ? DOUBLE_CLICK : 0; + if (mClickCounter >= 3) { + mClickCounter = 0; + } + + mLastClickTime = eventTime; + acquireWakeLockAndSendMessage(context, msg, delay); + } else { + startService(context, command); + } + + return true; + } + } + } + } + + return false; + } + + private static void startService(Context context, String command) { + final Intent intent = new Intent(context, MusicService.class); + intent.setAction(command); + try { + // IMPORTANT NOTE: (kind of a hack) + // on Android O and above the following crashes when the app is not running + // there is no good way to check whether the app is running so we catch the exception + // we do not always want to use startForegroundService() because then one gets an ANR + // if no notification is displayed via startForeground() + // according to Play analytics this happens a lot, I suppose for example if command = PAUSE + context.startService(intent); + } catch (IllegalStateException ignored) { + ContextCompat.startForegroundService(context, intent); + } + } + + private static void acquireWakeLockAndSendMessage(Context context, Message msg, long delay) { + if (mWakeLock == null) { + Context appContext = context.getApplicationContext(); + PowerManager pm = (PowerManager) appContext.getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, context.getClass().getName()); + mWakeLock.setReferenceCounted(false); + } + + if (DEBUG) Log.v(TAG, "Acquiring wake lock and sending " + msg.what); + + mWakeLock.acquire(10000); + mHandler.sendMessageDelayed(msg, delay); + } + + private static void releaseWakeLockIfHandlerIdle() { + if (mHandler.hasMessages(MSG_HEADSET_DOUBLE_CLICK_TIMEOUT)) { + if (DEBUG) Log.v(TAG, "Handler still has messages pending, not releasing wake lock"); + return; + } + + if (mWakeLock != null) { + if (DEBUG) Log.v(TAG, "Releasing wake lock"); + + mWakeLock.release(); + mWakeLock = null; + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/service/MultiPlayer.java b/app/src/main/java/com/cappielloantonio/play/service/MultiPlayer.java new file mode 100644 index 00000000..3e20b29a --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/service/MultiPlayer.java @@ -0,0 +1,290 @@ +package com.cappielloantonio.play.service; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.cappielloantonio.play.R; +import com.cappielloantonio.play.model.Song; +import com.cappielloantonio.play.service.playback.Playback; +import com.cappielloantonio.play.util.MusicUtil; +import com.cappielloantonio.play.util.PreferenceUtil; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.cache.CacheDataSink; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; + +import java.io.File; +import java.io.IOException; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Dispatcher; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class MultiPlayer implements Playback { + public static final String TAG = MultiPlayer.class.getSimpleName(); + + private final Context context; + private final OkHttpClient httpClient; + + private SimpleExoPlayer exoPlayer; + private ConcatenatingMediaSource mediaSource; + + private final SimpleCache simpleCache; + private final DataSource.Factory dataSource; + + private PlaybackCallbacks callbacks; + + private boolean isReady = false; + private boolean isPlaying = false; + + private boolean requestPlay = false; + private int requestProgress = 0; + + private final ExoPlayer.EventListener eventListener = new ExoPlayer.EventListener() { + @Override + public void onTracksChanged(@NonNull TrackGroupArray trackGroups, @NonNull TrackSelectionArray trackSelections) { + Log.i(TAG, "onTracksChanged"); + } + + @Override + public void onLoadingChanged(boolean isLoading) { + Log.i(TAG, "onLoadingChanged: " + isLoading); + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + Log.i(TAG, "onPlayerStateChanged playWhenReady: " + playWhenReady); + Log.i(TAG, "onPlayerStateChanged playbackState: " + playbackState); + + if (callbacks == null) return; + if (requestProgress != 0 && playbackState == Player.STATE_READY) { + exoPlayer.seekTo(requestProgress); + + requestProgress = 0; + } + + if (exoPlayer.isPlaying() || requestPlay && playbackState == ExoPlayer.STATE_READY) { + requestPlay = false; + isPlaying = true; + + exoPlayer.setPlayWhenReady(true); + callbacks.onTrackStarted(); + } + } + + @Override + public void onPositionDiscontinuity(int reason) { + Log.i(TAG, "onPositionDiscontinuity: " + reason); + int windowIndex = exoPlayer.getCurrentWindowIndex(); + + if (windowIndex == 1) { + mediaSource.removeMediaSource(0); + if (exoPlayer.isPlaying()) { + // there are still songs left in the queue + callbacks.onTrackWentToNext(); + } else { + callbacks.onTrackEnded(); + } + } + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + Log.i(TAG, "onPlayerError: " + error.getMessage()); + if (context == null) { + return; + } + + Toast.makeText(context, context.getResources().getString(R.string.unplayable_file), Toast.LENGTH_SHORT).show(); + exoPlayer.release(); + + exoPlayer = new SimpleExoPlayer.Builder(context).build(); + isReady = false; + } + }; + + public MultiPlayer(Context context) { + this.context = context; + + Dispatcher dispatcher = new Dispatcher(); + dispatcher.setMaxRequests(1); + + httpClient = new OkHttpClient.Builder().dispatcher(dispatcher).build(); + + exoPlayer = new SimpleExoPlayer.Builder(context).build(); + mediaSource = new ConcatenatingMediaSource(); + + exoPlayer.addListener(eventListener); + exoPlayer.prepare(mediaSource); + exoPlayer.setRepeatMode(Player.REPEAT_MODE_OFF); + + long cacheSize = PreferenceUtil.getInstance(context).getMediaCacheSize(); + LeastRecentlyUsedCacheEvictor recentlyUsedCache = new LeastRecentlyUsedCacheEvictor(cacheSize); + ExoDatabaseProvider databaseProvider = new ExoDatabaseProvider(context); + + File cacheDirectory = new File(context.getCacheDir(), "exoplayer"); + simpleCache = new SimpleCache(cacheDirectory, recentlyUsedCache, databaseProvider); + dataSource = buildDataSourceFactory(); + } + + @Override + public void setDataSource(Song song) { + isReady = false; + mediaSource = new ConcatenatingMediaSource(); + + exoPlayer.addListener(eventListener); + exoPlayer.prepare(mediaSource); + + // queue and other information is currently handled outside exoplayer + exoPlayer.setRepeatMode(Player.REPEAT_MODE_OFF); + + appendDataSource(MusicUtil.getSongFileUri(song)); + } + + @Override + public void queueDataSource(Song song) { + String path = MusicUtil.getSongFileUri(song); + if (mediaSource.getSize() == 2 && mediaSource.getMediaSource(1).getTag() != path) { + mediaSource.removeMediaSource(1); + } + + if (mediaSource.getSize() != 2) { + appendDataSource(path); + } + } + + private void appendDataSource(String path) { + Uri uri = Uri.parse(path); + + httpClient.newCall(new Request.Builder().url(path).head().build()).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Toast.makeText(context, context.getResources().getString(R.string.unplayable_file), Toast.LENGTH_SHORT).show(); + e.printStackTrace(); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + MediaSource source; + if (response.header("Content-Type").equals("application/x-mpegURL")) { + source = new HlsMediaSource.Factory(dataSource) + .setTag(path) + .setAllowChunklessPreparation(true) + .createMediaSource(uri); + } else { + source = new ProgressiveMediaSource.Factory(dataSource) + .setTag(path) + .createMediaSource(uri); + } + + mediaSource.addMediaSource(source); + isReady = true; + } + }); + } + + private DataSource.Factory buildDataSourceFactory() { + return () -> new CacheDataSource( + simpleCache, + new DefaultDataSourceFactory(context, context.getPackageName(), null).createDataSource(), + new FileDataSource(), + new CacheDataSink(simpleCache, 10 * 1024 * 1024), + CacheDataSource.FLAG_BLOCK_ON_CACHE, + null + ); + } + + @Override + public void setCallbacks(Playback.PlaybackCallbacks callbacks) { + this.callbacks = callbacks; + } + + @Override + public boolean isReady() { + return isReady; + } + + @Override + public boolean isPlaying() { + return isReady && isPlaying; + } + + @Override + public void start() { + if (!isReady) { + requestPlay = true; + return; + } + + isPlaying = true; + exoPlayer.setPlayWhenReady(true); + } + + @Override + public void pause() { + isPlaying = false; + exoPlayer.setPlayWhenReady(false); + } + + @Override + public void stop() { + simpleCache.release(); + exoPlayer.release(); + + exoPlayer = null; + isReady = false; + } + + @Override + public int getProgress() { + if (!isReady) return -1; + return (int) exoPlayer.getCurrentPosition(); + } + + @Override + public int getDuration() { + if (!isReady) return -1; + return (int) exoPlayer.getDuration(); + } + + @Override + public void setProgress(int progress) { + if (!isReady) { + requestProgress = progress; + return; + } + + exoPlayer.seekTo(progress); + } + + @Override + public void setVolume(int volume) { + exoPlayer.setVolume(volume / 100f); + } + + @Override + public int getVolume() { + return (int) (exoPlayer.getVolume() * 100); + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/service/MusicService.java b/app/src/main/java/com/cappielloantonio/play/service/MusicService.java index 15e3434f..34c978e7 100644 --- a/app/src/main/java/com/cappielloantonio/play/service/MusicService.java +++ b/app/src/main/java/com/cappielloantonio/play/service/MusicService.java @@ -1,97 +1,1100 @@ package com.cappielloantonio.play.service; -import android.app.Notification; +import android.annotation.SuppressLint; +import android.app.PendingIntent; import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.media.AudioManager; -import android.net.Uri; +import android.os.Binder; +import android.os.Handler; +import android.os.HandlerThread; import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.PowerManager; +import android.os.Process; +import android.preference.PreferenceManager; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; +import com.cappielloantonio.play.App; import com.cappielloantonio.play.R; -import com.google.android.exoplayer2.ExoPlayerFactory; -import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.util.Util; +import com.cappielloantonio.play.model.Playlist; +import com.cappielloantonio.play.model.Song; +import com.cappielloantonio.play.service.notification.PlayingNotification; +import com.cappielloantonio.play.service.playback.Playback; +import com.cappielloantonio.play.util.PreferenceUtil; -public class MusicService extends Service implements AudioManager.OnAudioFocusChangeListener { +import org.jellyfin.apiclient.interaction.EmptyResponse; +import org.jellyfin.apiclient.interaction.Response; +import org.jellyfin.apiclient.model.session.PlaybackProgressInfo; +import org.jellyfin.apiclient.model.session.PlaybackStartInfo; +import org.jellyfin.apiclient.model.session.PlaybackStopInfo; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class MusicService extends Service implements Playback.PlaybackCallbacks { + public static final String PACKAGE_NAME = "com.dkanada.gramophone"; + + public static final String ACTION_TOGGLE = PACKAGE_NAME + ".toggle"; + public static final String ACTION_PLAY = PACKAGE_NAME + ".play"; + public static final String ACTION_PLAY_PLAYLIST = PACKAGE_NAME + ".play.playlist"; + public static final String ACTION_PAUSE = PACKAGE_NAME + ".pause"; + public static final String ACTION_STOP = PACKAGE_NAME + ".stop"; + public static final String ACTION_SKIP = PACKAGE_NAME + ".skip"; + public static final String ACTION_REWIND = PACKAGE_NAME + ".rewind"; + public static final String ACTION_QUIT = PACKAGE_NAME + ".quit"; + public static final String ACTION_PENDING_QUIT = PACKAGE_NAME + ".quit.pending"; + + public static final String INTENT_EXTRA_PLAYLIST = PACKAGE_NAME + ".extra.playlist"; + public static final String INTENT_EXTRA_SHUFFLE = PACKAGE_NAME + ".extra.shuffle"; + + public static final String STATE_CHANGED = PACKAGE_NAME + ".state.changed"; + public static final String META_CHANGED = PACKAGE_NAME + ".meta.changed"; + public static final String QUEUE_CHANGED = PACKAGE_NAME + ".queue.changed"; + + public static final String REPEAT_MODE_CHANGED = PACKAGE_NAME + ".repeat.changed"; + public static final String SHUFFLE_MODE_CHANGED = PACKAGE_NAME + ".shuffle.changed"; + + public static final int TRACK_STARTED = 9; + public static final int TRACK_CHANGED = 1; + public static final int TRACK_ENDED = 2; + + public static final int RELEASE_WAKELOCK = 0; + public static final int PLAY_SONG = 3; + public static final int PREPARE_NEXT = 4; + public static final int SET_POSITION = 5; + public static final int FOCUS_CHANGE = 6; + public static final int DUCK = 7; + public static final int UNDUCK = 8; + + public static final int SHUFFLE_MODE_NONE = 0; + public static final int SHUFFLE_MODE_SHUFFLE = 1; + + public static final int REPEAT_MODE_NONE = 0; + public static final int REPEAT_MODE_ALL = 1; + public static final int REPEAT_MODE_THIS = 2; + + public static final int SAVE_QUEUE = 0; + public static final int LOAD_QUEUE = 9; + + private final IBinder musicBinder = new MusicBinder(); + + public boolean pendingQuit = false; + + private Playback playback; + + private List playingQueue = new ArrayList<>(); + private List originalPlayingQueue = new ArrayList<>(); + + private int position = -1; + private int nextPosition = -1; + + private int repeatMode; + + private boolean notHandledMetaChangedForCurrentTrack; + private boolean queuesRestored; + private boolean pausedByTransientLossOfFocus; + + private PlayingNotification playingNotification; private AudioManager audioManager; - private MediaSource mediaSource; - private SimpleExoPlayer player; + private MediaSessionCompat mediaSession; + private PowerManager.WakeLock wakeLock; + + private PlaybackHandler playerHandler; + private Handler uiThreadHandler; + private ThrottledSeekHandler throttledSeekHandler; + private QueueHandler queueHandler; + private ProgressHandler progressHandler; + + private HandlerThread playerHandlerThread; + private HandlerThread progressHandlerThread; + private HandlerThread queueHandlerThread; + + private final BroadcastReceiver becomingNoisyReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, @NonNull Intent intent) { + if (intent.getAction().equals(AudioManager.ACTION_AUDIO_BECOMING_NOISY)) { + pause(); + } + } + }; + + private final AudioManager.OnAudioFocusChangeListener audioFocusListener = new AudioManager.OnAudioFocusChangeListener() { + @Override + public void onAudioFocusChange(final int focusChange) { + playerHandler.obtainMessage(FOCUS_CHANGE, focusChange, 0).sendToTarget(); + } + }; + + private static final long MEDIA_SESSION_ACTIONS = PlaybackStateCompat.ACTION_PLAY + | PlaybackStateCompat.ACTION_PAUSE + | PlaybackStateCompat.ACTION_PLAY_PAUSE + | PlaybackStateCompat.ACTION_SKIP_TO_NEXT + | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + | PlaybackStateCompat.ACTION_STOP + | PlaybackStateCompat.ACTION_SEEK_TO; @Override public void onCreate() { - audioManager = (AudioManager) getApplicationContext().getSystemService(AUDIO_SERVICE); super.onCreate(); + + final PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, getClass().getName()); + wakeLock.setReferenceCounted(false); + + playback = new MultiPlayer(this); + playback.setCallbacks(this); + + playerHandlerThread = new HandlerThread(PlaybackHandler.class.getName()); + playerHandlerThread.start(); + playerHandler = new PlaybackHandler(this, playerHandlerThread.getLooper()); + + progressHandlerThread = new HandlerThread(ProgressHandler.class.getName()); + progressHandlerThread.start(); + progressHandler = new ProgressHandler(this, progressHandlerThread.getLooper()); + + queueHandlerThread = new HandlerThread(QueueHandler.class.getName(), Process.THREAD_PRIORITY_BACKGROUND); + queueHandlerThread.start(); + queueHandler = new QueueHandler(this, queueHandlerThread.getLooper()); + + throttledSeekHandler = new ThrottledSeekHandler(playerHandler); + uiThreadHandler = new Handler(); + + registerReceiver(becomingNoisyReceiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + + initNotification(); + initMediaSession(); + restoreState(); + + mediaSession.setActive(true); + } + + private AudioManager getAudioManager() { + if (audioManager == null) { + audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + } + + return audioManager; + } + + private void initMediaSession() { + ComponentName mediaButtonReceiverComponentName = new ComponentName(getApplicationContext(), MediaButtonIntentReceiver.class); + + Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + mediaButtonIntent.setComponent(mediaButtonReceiverComponentName); + + PendingIntent mediaButtonReceiverPendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 0, mediaButtonIntent, 0); + + mediaSession = new MediaSessionCompat(this, getResources().getString(R.string.app_name), mediaButtonReceiverComponentName, mediaButtonReceiverPendingIntent); + mediaSession.setCallback(new MediaSessionCompat.Callback() { + @Override + public void onPlay() { + play(); + } + + @Override + public void onPause() { + pause(); + } + + @Override + public void onSkipToNext() { + playNextSong(true); + } + + @Override + public void onSkipToPrevious() { + back(true); + } + + @Override + public void onStop() { + quit(); + } + + @Override + public void onSeekTo(long pos) { + seek((int) pos); + } + + @Override + public boolean onMediaButtonEvent(Intent mediaButtonEvent) { + return MediaButtonIntentReceiver.handleIntent(MusicService.this, mediaButtonEvent); + } + }); + + mediaSession.setMediaButtonReceiver(mediaButtonReceiverPendingIntent); } @Override - public int onStartCommand(Intent intent, int flags, int startId) { - String input = intent.getStringExtra("playStop"); - if (input != null && input.equals("play")) { - if (requestFocus()) { - initPlayer(); - play(); + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + if (intent != null) { + if (intent.getAction() != null) { + String action = intent.getAction(); + switch (action) { + case ACTION_TOGGLE: + if (isPlaying()) { + pause(); + } else { + play(); + } + break; + case ACTION_PAUSE: + pause(); + break; + case ACTION_PLAY: + play(); + break; + case ACTION_PLAY_PLAYLIST: + Playlist playlist = intent.getParcelableExtra(INTENT_EXTRA_PLAYLIST); + if (playlist != null) { + List playlistSongs = new ArrayList<>(); + if (!playlistSongs.isEmpty()) { + openQueue(playlistSongs, 0, true); + } else { + Toast.makeText(getApplicationContext(), R.string.playlist_is_empty, Toast.LENGTH_LONG).show(); + } + } else { + Toast.makeText(getApplicationContext(), R.string.playlist_is_empty, Toast.LENGTH_LONG).show(); + } + break; + case ACTION_REWIND: + back(true); + break; + case ACTION_SKIP: + playNextSong(true); + break; + case ACTION_STOP: + case ACTION_QUIT: + pendingQuit = false; + quit(); + break; + case ACTION_PENDING_QUIT: + pendingQuit = true; + break; + } } - } else { - stop(); } - startForeground(1, getNotification()); + return START_NOT_STICKY; } @Override public void onDestroy() { - stop(); - super.onDestroy(); + unregisterReceiver(becomingNoisyReceiver); + + progressHandler.sendEmptyMessage(TRACK_ENDED); + mediaSession.setActive(false); + quit(); + releaseResources(); + wakeLock.release(); } - @Nullable @Override public IBinder onBind(Intent intent) { - return null; + return musicBinder; } - private Notification getNotification() { - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setContentText("Running ...") - .setContentTitle("Play") - .setOngoing(true) - .setSmallIcon(R.drawable.exo_controls_shuffle_on) - .setChannelId("PlayApp"); - return builder.build(); + private static final class QueueHandler extends Handler { + @NonNull + private final WeakReference mService; + + public QueueHandler(final MusicService service, @NonNull final Looper looper) { + super(looper); + mService = new WeakReference<>(service); + } + + @Override + public void handleMessage(@NonNull Message msg) { + final MusicService service = mService.get(); + switch (msg.what) { + case LOAD_QUEUE: + service.restoreQueuesAndPositionIfNecessary(); + break; + case SAVE_QUEUE: + service.saveQueue(); + break; + } + } } - @Override - public void onAudioFocusChange(int focusChange) { - if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || focusChange == AudioManager.AUDIOFOCUS_LOSS) { - stop(); - System.exit(0); + private void saveQueue() { + App.getDatabase().songDao().deleteSongs(); + App.getDatabase().songDao().insertSongs(playingQueue); + + App.getDatabase().queueSongDao().deleteQueueSongs(); + App.getDatabase().queueSongDao().setQueue(playingQueue, 0); + App.getDatabase().queueSongDao().setQueue(originalPlayingQueue, 1); + } + + private void savePosition() { + PreferenceManager.getDefaultSharedPreferences(this).edit().putInt(PreferenceUtil.POSITION, getPosition()).apply(); + } + + private void saveProgress() { + PreferenceManager.getDefaultSharedPreferences(this).edit().putInt(PreferenceUtil.PROGRESS, getSongProgressMillis()).apply(); + } + + public void saveState() { + queueHandler.removeMessages(SAVE_QUEUE); + queueHandler.sendEmptyMessage(SAVE_QUEUE); + + savePosition(); + saveProgress(); + } + + private void restoreState() { + repeatMode = PreferenceManager.getDefaultSharedPreferences(this).getInt(PreferenceUtil.REPEAT, 0); + + notifyChange(REPEAT_MODE_CHANGED); + + queueHandler.removeMessages(LOAD_QUEUE); + queueHandler.sendEmptyMessage(LOAD_QUEUE); + } + + private synchronized void restoreQueuesAndPositionIfNecessary() { + if (!queuesRestored && playingQueue.isEmpty()) { + List restoredQueue = App.getDatabase().queueSongDao().getQueue(0); + List restoredOriginalQueue = App.getDatabase().queueSongDao().getQueue(1); + + int restoredPosition = PreferenceManager.getDefaultSharedPreferences(this).getInt(PreferenceUtil.POSITION, -1); + int restoredPositionInTrack = PreferenceManager.getDefaultSharedPreferences(this).getInt(PreferenceUtil.PROGRESS, -1); + + if (restoredQueue.size() > 0 && restoredQueue.size() == restoredOriginalQueue.size() && restoredPosition != -1) { + this.originalPlayingQueue = restoredOriginalQueue; + this.playingQueue = restoredQueue; + + position = restoredPosition; + openCurrent(); + + if (restoredPositionInTrack > 0) seek(restoredPositionInTrack); + + notHandledMetaChangedForCurrentTrack = true; + sendChangeInternal(META_CHANGED); + sendChangeInternal(QUEUE_CHANGED); + } + } + + queuesRestored = true; + } + + private void quit() { + pause(); + playingNotification.stop(); + + getAudioManager().abandonAudioFocus(audioFocusListener); + stopSelf(); + } + + private void releaseResources() { + playerHandler.removeCallbacksAndMessages(null); + playerHandlerThread.quitSafely(); + + progressHandler.removeCallbacksAndMessages(null); + progressHandlerThread.quitSafely(); + + queueHandler.removeCallbacksAndMessages(null); + queueHandlerThread.quitSafely(); + + playback.stop(); + mediaSession.release(); + } + + public boolean isPlaying() { + return playback != null && playback.isPlaying(); + } + + public int getPosition() { + return position; + } + + public void playNextSong(boolean force) { + playSongAt(getNextPosition(force)); + } + + private void openTrackAndPrepareNextAt(int position) { + synchronized (this) { + this.position = position; + + openCurrent(); + playback.start(); + + notifyChange(META_CHANGED); + notHandledMetaChangedForCurrentTrack = false; + } + } + + private void openCurrent() { + synchronized (this) { + // current song will be null when queue is cleared + if (getCurrentSong() == null) return; + + playback.setDataSource(getCurrentSong()); + } + } + + private void prepareNext() { + playerHandler.removeMessages(PREPARE_NEXT); + playerHandler.obtainMessage(PREPARE_NEXT).sendToTarget(); + } + + private void prepareNextImpl() { + synchronized (this) { + nextPosition = getNextPosition(false); + playback.queueDataSource(getSongAt(nextPosition)); } } private boolean requestFocus() { - return (audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED); + return getAudioManager().requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; } - private void play() { - player.setForegroundMode(true); - player.prepare(mediaSource); - player.setPlayWhenReady(true); + public void initNotification() { + playingNotification = new PlayingNotification(); + + playingNotification.init(this); } - private void stop() { - player.setPlayWhenReady(false); - player.stop(); + public void updateNotification() { + if (playingNotification != null && getCurrentSong().getId() != null) { + playingNotification.update(); + } } - private void initPlayer() { - player = ExoPlayerFactory.newSimpleInstance(getApplicationContext()); - DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(this, Util.getUserAgent(getApplicationContext(), "exoPlayerSample")); - mediaSource = new ProgressiveMediaSource.Factory(defaultDataSourceFactory).createMediaSource(Uri.parse("http://192.168.1.81:8096/Audio/5656e9fd11e38ba95ba4871bc061991a/universal?UserId=34addd030b4545e5ac4300dc322c9f73&DeviceId=e40853e4e7ab76f1&MaxStreamingBitrate=10000000&Container=flac|flac,mp3|mp3,opus|opus,m4a|aac,ogg|vorbis,ogg|opus,mka|opus&TranscodingContainer=ts&TranscodingProtocol=hls&AudioCodec=aac&api_key=7e6626ca220d4b01961022e148868d41")); + private void updateMediaSessionState() { + mediaSession.setPlaybackState( + new PlaybackStateCompat.Builder() + .setActions(MEDIA_SESSION_ACTIONS) + .setState(isPlaying() ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED, getSongProgressMillis(), 1) + .build()); + } + + @SuppressLint("CheckResult") + private void updateMediaSessionMetadata() { + final Song song = getCurrentSong(); + + if (song.getId() == null) { + mediaSession.setMetadata(null); + return; + } + + final MediaMetadataCompat.Builder metaData = new MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.getArtistName()) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.getArtistName()) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.getAlbumName()) + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.getTitle()) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.getDuration()) + .putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, getPosition() + 1) + .putLong(MediaMetadataCompat.METADATA_KEY_YEAR, song.getYear()) + .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, null); + + metaData.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, getPlayingQueue().size()); + } + + public void runOnUiThread(Runnable runnable) { + uiThreadHandler.post(runnable); + } + + public Song getCurrentSong() { + return getSongAt(getPosition()); + } + + public Song getSongAt(int position) { + return getPlayingQueue().get(position); + } + + public int getNextPosition(boolean force) { + int position = getPosition() + 1; + switch (getRepeatMode()) { + case REPEAT_MODE_ALL: + if (isLastTrack()) { + position = 0; + } + break; + case REPEAT_MODE_THIS: + if (force) { + if (isLastTrack()) { + position = 0; + } + } else { + position -= 1; + } + break; + default: + case REPEAT_MODE_NONE: + if (isLastTrack()) { + position -= 1; + } + break; + } + + return position; + } + + private boolean isLastTrack() { + return getPosition() == getPlayingQueue().size() - 1; + } + + public List getPlayingQueue() { + return playingQueue; + } + + public int getRepeatMode() { + return repeatMode; + } + + public void setRepeatMode(final int repeatMode) { + switch (repeatMode) { + case REPEAT_MODE_NONE: + case REPEAT_MODE_ALL: + case REPEAT_MODE_THIS: + this.repeatMode = repeatMode; + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putInt(PreferenceUtil.REPEAT, repeatMode) + .apply(); + prepareNext(); + notifyChange(REPEAT_MODE_CHANGED); + break; + } + } + + public void openQueue(@Nullable final List playingQueue, final int startPosition, final boolean startPlaying) { + if (playingQueue != null && !playingQueue.isEmpty() && startPosition >= 0 && startPosition < playingQueue.size()) { + // it is important to copy the playing queue here first as we might add or remove songs later + originalPlayingQueue = new ArrayList<>(playingQueue); + this.playingQueue = new ArrayList<>(originalPlayingQueue); + + if (startPlaying) { + playSongAt(startPosition); + } else { + setPosition(startPosition); + } + + notifyChange(QUEUE_CHANGED); + } + } + + public void addSong(int position, Song song) { + playingQueue.add(position, song); + originalPlayingQueue.add(position, song); + notifyChange(QUEUE_CHANGED); + } + + public void addSong(Song song) { + playingQueue.add(song); + originalPlayingQueue.add(song); + notifyChange(QUEUE_CHANGED); + } + + public void addSongs(int position, List songs) { + playingQueue.addAll(position, songs); + originalPlayingQueue.addAll(position, songs); + notifyChange(QUEUE_CHANGED); + } + + public void addSongs(List songs) { + playingQueue.addAll(songs); + originalPlayingQueue.addAll(songs); + notifyChange(QUEUE_CHANGED); + } + + public void removeSong(int position) { + originalPlayingQueue.remove(playingQueue.remove(position)); + reposition(position); + notifyChange(QUEUE_CHANGED); + } + + private void reposition(int deletedPosition) { + int currentPosition = getPosition(); + if (deletedPosition < currentPosition) { + position = currentPosition - 1; + } else if (deletedPosition == currentPosition) { + if (playingQueue.size() > deletedPosition) { + setPosition(position); + } else { + setPosition(position - 1); + } + } + } + + public void moveSong(int from, int to) { + if (from == to) return; + final int currentPosition = getPosition(); + Song songToMove = playingQueue.remove(from); + playingQueue.add(to, songToMove); + + if (from > currentPosition && to <= currentPosition) { + position = currentPosition + 1; + } else if (from < currentPosition && to >= currentPosition) { + position = currentPosition - 1; + } else if (from == currentPosition) { + position = to; + } + + notifyChange(QUEUE_CHANGED); + } + + public void clearQueue() { + playingQueue.clear(); + originalPlayingQueue.clear(); + + setPosition(-1); + notifyChange(QUEUE_CHANGED); + } + + public void playSongAt(final int position) { + // handle this on the handlers thread to avoid blocking the ui thread + playerHandler.removeMessages(PLAY_SONG); + playerHandler.obtainMessage(PLAY_SONG, position, 0).sendToTarget(); + } + + public void setPosition(final int position) { + // handle this on the handlers thread to avoid blocking the ui thread + playerHandler.removeMessages(SET_POSITION); + playerHandler.obtainMessage(SET_POSITION, position, 0).sendToTarget(); + } + + private void playSongAtImpl(int position) { + openTrackAndPrepareNextAt(position); + } + + public void pause() { + pausedByTransientLossOfFocus = false; + if (playback.isPlaying()) { + playback.pause(); + notifyChange(STATE_CHANGED); + } + } + + public void play() { + synchronized (this) { + if (requestFocus()) { + if (!playback.isPlaying()) { + if (!playback.isReady()) { + playSongAt(getPosition()); + } else { + playback.start(); + if (notHandledMetaChangedForCurrentTrack) { + handleChangeInternal(META_CHANGED); + notHandledMetaChangedForCurrentTrack = false; + } + + notifyChange(STATE_CHANGED); + + // fixes a bug where the volume would stay ducked + // happens when audio focus GAIN event not sent + playerHandler.removeMessages(DUCK); + playerHandler.sendEmptyMessage(UNDUCK); + } + } + } else { + Toast.makeText(this, getResources().getString(R.string.audio_focus_denied), Toast.LENGTH_SHORT).show(); + } + } + } + + public void playPreviousSong(boolean force) { + playSongAt(getPreviousPosition(force)); + } + + public void back(boolean force) { + if (getSongProgressMillis() > 5000) { + seek(0); + } else { + playPreviousSong(force); + } + } + + public int getPreviousPosition(boolean force) { + int newPosition = getPosition() - 1; + switch (repeatMode) { + case REPEAT_MODE_ALL: + if (newPosition < 0) { + newPosition = getPlayingQueue().size() - 1; + } + break; + case REPEAT_MODE_THIS: + if (force) { + if (newPosition < 0) { + newPosition = getPlayingQueue().size() - 1; + } + } else { + newPosition = getPosition(); + } + break; + default: + case REPEAT_MODE_NONE: + if (newPosition < 0) { + newPosition = 0; + } + break; + } + + return newPosition; + } + + public int getSongProgressMillis() { + return playback.getProgress(); + } + + public int getSongDurationMillis() { + return playback.getDuration(); + } + + public long getQueueDurationMillis(int position) { + long duration = 0; + for (int i = position + 1; i < playingQueue.size(); i++) { + duration += playingQueue.get(i).getDuration(); + } + + return duration; + } + + public int seek(int millis) { + synchronized (this) { + playback.setProgress(millis); + throttledSeekHandler.notifySeek(); + return millis; + } + } + + public void cycleRepeatMode() { + switch (getRepeatMode()) { + case REPEAT_MODE_NONE: + setRepeatMode(REPEAT_MODE_ALL); + break; + case REPEAT_MODE_ALL: + setRepeatMode(REPEAT_MODE_THIS); + break; + default: + setRepeatMode(REPEAT_MODE_NONE); + break; + } + } + + private void notifyChange(@NonNull final String what) { + handleChangeInternal(what); + sendChangeInternal(what); + } + + private void sendChangeInternal(final String what) { + sendBroadcast(new Intent(what)); + } + + private void handleChangeInternal(@NonNull final String what) { + switch (what) { + case STATE_CHANGED: + updateNotification(); + updateMediaSessionState(); + if (!isPlaying()) saveProgress(); + break; + case META_CHANGED: + updateNotification(); + updateMediaSessionMetadata(); + updateMediaSessionState(); + savePosition(); + saveProgress(); + break; + case QUEUE_CHANGED: + // because playing queue size might have changed + updateMediaSessionMetadata(); + saveState(); + if (playingQueue.size() > 0) { + prepareNext(); + } else { + playingNotification.stop(); + } + break; + } + } + + public MediaSessionCompat getMediaSession() { + return mediaSession; + } + + public void releaseWakeLock() { + if (wakeLock.isHeld()) { + wakeLock.release(); + } + } + + public void acquireWakeLock(long milli) { + wakeLock.acquire(milli); + } + + @Override + public void onTrackStarted() { + progressHandler.sendEmptyMessage(TRACK_STARTED); + + notifyChange(STATE_CHANGED); + prepareNext(); + } + + @Override + public void onTrackWentToNext() { + playerHandler.sendEmptyMessage(TRACK_CHANGED); + progressHandler.sendEmptyMessage(TRACK_CHANGED); + + acquireWakeLock(30000); + } + + @Override + public void onTrackEnded() { + playerHandler.sendEmptyMessage(TRACK_ENDED); + progressHandler.sendEmptyMessage(TRACK_ENDED); + } + + private static final class PlaybackHandler extends Handler { + private final WeakReference mService; + private int currentDuckVolume = 100; + + public PlaybackHandler(final MusicService service, @NonNull final Looper looper) { + super(looper); + mService = new WeakReference<>(service); + } + + @Override + public void handleMessage(@NonNull final Message msg) { + final MusicService service = mService.get(); + if (service == null) { + return; + } + + switch (msg.what) { + case DUCK: + if (PreferenceUtil.getInstance(service).getAudioDucking()) { + currentDuckVolume -= 5; + if (currentDuckVolume > 20) { + sendEmptyMessageDelayed(DUCK, 10); + } else { + currentDuckVolume = 20; + } + } else { + currentDuckVolume = 100; + } + + service.playback.setVolume(currentDuckVolume); + break; + + case UNDUCK: + if (PreferenceUtil.getInstance(service).getAudioDucking()) { + currentDuckVolume += 3; + if (currentDuckVolume < 100) { + sendEmptyMessageDelayed(UNDUCK, 10); + } else { + currentDuckVolume = 100; + } + } else { + currentDuckVolume = 100; + } + + service.playback.setVolume(currentDuckVolume); + break; + + case TRACK_CHANGED: + if (service.getRepeatMode() == REPEAT_MODE_NONE && service.isLastTrack()) { + service.pause(); + service.seek(0); + service.notifyChange(STATE_CHANGED); + } else { + service.position = service.nextPosition; + service.prepareNextImpl(); + service.notifyChange(META_CHANGED); + } + break; + + case TRACK_ENDED: + // if there is a timer finished, don't continue + if (service.pendingQuit || service.getRepeatMode() == REPEAT_MODE_NONE && service.isLastTrack()) { + service.notifyChange(STATE_CHANGED); + service.seek(0); + if (service.pendingQuit) { + service.pendingQuit = false; + service.quit(); + break; + } + } else { + service.playNextSong(false); + } + + sendEmptyMessage(RELEASE_WAKELOCK); + break; + + case RELEASE_WAKELOCK: + service.releaseWakeLock(); + break; + + case PLAY_SONG: + service.playSongAtImpl(msg.arg1); + service.notifyChange(STATE_CHANGED); + break; + + case SET_POSITION: + service.openTrackAndPrepareNextAt(msg.arg1); + service.notifyChange(STATE_CHANGED); + break; + + case PREPARE_NEXT: + service.prepareNextImpl(); + break; + + case FOCUS_CHANGE: + switch (msg.arg1) { + case AudioManager.AUDIOFOCUS_GAIN: + if (!service.isPlaying() && service.pausedByTransientLossOfFocus) { + service.play(); + service.pausedByTransientLossOfFocus = false; + } + removeMessages(DUCK); + sendEmptyMessage(UNDUCK); + break; + + case AudioManager.AUDIOFOCUS_LOSS: + // Lost focus for an unbounded amount of time: stop playback and release media playback + service.pause(); + break; + + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + // Lost focus for a short time, but we have to stop + // playback. We don't release the media playback because playback + // is likely to resume + boolean wasPlaying = service.isPlaying(); + service.pause(); + service.pausedByTransientLossOfFocus = wasPlaying; + break; + + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + // Lost focus for a short time, but it's ok to keep playing + // at an attenuated level + removeMessages(UNDUCK); + sendEmptyMessage(DUCK); + break; + } + break; + } + } + } + + public class MusicBinder extends Binder { + @NonNull + public MusicService getService() { + return MusicService.this; + } + } + + private class ThrottledSeekHandler implements Runnable { + // milliseconds to throttle before calling run to aggregate events + private static final long THROTTLE = 500; + private final Handler mHandler; + + public ThrottledSeekHandler(Handler handler) { + mHandler = handler; + } + + public void notifySeek() { + mHandler.removeCallbacks(this); + mHandler.postDelayed(this, THROTTLE); + } + + @Override + public void run() { + notifyChange(STATE_CHANGED); + } + } + + private static final class ProgressHandler extends Handler { + private final WeakReference mService; + + private ScheduledExecutorService executorService; + private Future task; + + public ProgressHandler(MusicService service, Looper looper) { + super(looper); + + mService = new WeakReference<>(service); + } + + @Override + public void handleMessage(@NonNull final Message msg) { + switch (msg.what) { + case TRACK_STARTED: + onStart(); + case TRACK_CHANGED: + onNext(); + break; + case TRACK_ENDED: + onStop(); + } + } + + public void onStart() { + if (executorService != null) executorService.shutdownNow(); + + executorService = Executors.newScheduledThreadPool(1); + task = executorService.scheduleAtFixedRate(this::onProgress, 10, 10, TimeUnit.SECONDS); + } + + public void onNext() { + PlaybackStartInfo startInfo = new PlaybackStartInfo(); + + startInfo.setItemId(mService.get().getCurrentSong().getId()); + startInfo.setVolumeLevel(mService.get().playback.getVolume()); + startInfo.setCanSeek(true); + startInfo.setIsPaused(false); + + App.getInstance().getApiClientInstance(App.getInstance()).ensureWebSocket(); + App.getInstance().getApiClientInstance(App.getInstance()).ReportPlaybackStartAsync(startInfo, new EmptyResponse()); + } + + public void onProgress() { + PlaybackProgressInfo progressInfo = new PlaybackProgressInfo(); + + // TODO these cause a wrong thread error + long progress = mService.get().getSongProgressMillis(); + double duration = mService.get().getSongDurationMillis(); + if (progress / duration > 0.9) { + Song current = mService.get().getCurrentSong(); + String user = App.getInstance().getApiClientInstance(App.getInstance()).getCurrentUserId(); + Date time = new Date(System.currentTimeMillis()); + + App.getInstance().getApiClientInstance(App.getInstance()).MarkPlayedAsync(current.getId(), user, time, new Response<>()); + } + + progressInfo.setItemId(mService.get().getCurrentSong().getId()); + progressInfo.setPositionTicks(progress * 10000); + progressInfo.setVolumeLevel(mService.get().playback.getVolume()); + progressInfo.setIsPaused(!mService.get().playback.isPlaying()); + progressInfo.setCanSeek(true); + + App.getInstance().getApiClientInstance(App.getInstance()).ReportPlaybackProgressAsync(progressInfo, new EmptyResponse()); + } + + public void onStop() { + PlaybackStopInfo info = new PlaybackStopInfo(); + long progress = mService.get().getSongProgressMillis(); + + info.setItemId(mService.get().getCurrentSong().getId()); + info.setPositionTicks(progress * 10000); + + task.cancel(true); + executorService.shutdownNow(); + } } } diff --git a/app/src/main/java/com/cappielloantonio/play/service/notification/PlayingNotification.java b/app/src/main/java/com/cappielloantonio/play/service/notification/PlayingNotification.java new file mode 100644 index 00000000..d98fa520 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/service/notification/PlayingNotification.java @@ -0,0 +1,181 @@ +package com.cappielloantonio.play.service.notification; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.palette.graphics.Palette; + +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.cappielloantonio.play.R; +import com.cappielloantonio.play.glide.CustomGlideRequest; +import com.cappielloantonio.play.glide.palette.BitmapPaletteWrapper; +import com.cappielloantonio.play.model.Song; +import com.cappielloantonio.play.service.MusicService; +import com.cappielloantonio.play.ui.activities.MainActivity; + +import static android.content.Context.NOTIFICATION_SERVICE; +import static com.cappielloantonio.play.service.MusicService.ACTION_REWIND; +import static com.cappielloantonio.play.service.MusicService.ACTION_SKIP; +import static com.cappielloantonio.play.service.MusicService.ACTION_TOGGLE; + +public class PlayingNotification { + + private static final int NOTIFICATION_ID = 1; + static final String NOTIFICATION_CHANNEL_ID = "playing_notification"; + + private static final int NOTIFY_MODE_FOREGROUND = 1; + private static final int NOTIFY_MODE_BACKGROUND = 0; + + private int notifyMode = NOTIFY_MODE_BACKGROUND; + + private NotificationManager notificationManager; + protected MusicService service; + boolean stopped; + + public synchronized void init(MusicService service) { + this.service = service; + notificationManager = (NotificationManager) service.getSystemService(NOTIFICATION_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(); + } + } + + public synchronized void update() { + stopped = false; + + final Song song = service.getCurrentSong(); + + final boolean isPlaying = service.isPlaying(); + + final int playButtonResId = isPlaying ? R.drawable.ic_pause_white_24dp : R.drawable.ic_play_arrow_white_24dp; + + Intent action = new Intent(service, MainActivity.class); + action.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + final PendingIntent clickIntent = PendingIntent.getActivity(service, 0, action, 0); + + final ComponentName serviceName = new ComponentName(service, MusicService.class); + Intent intent = new Intent(MusicService.ACTION_QUIT); + intent.setComponent(serviceName); + final PendingIntent deleteIntent = PendingIntent.getService(service, 0, intent, 0); + + final int bigNotificationImageSize = service.getResources().getDimensionPixelSize(R.dimen.notification_big_image_size); + service.runOnUiThread(() -> CustomGlideRequest.Builder + .from(service, song.getPrimary(), song.getBlurHash(), CustomGlideRequest.PRIMARY, CustomGlideRequest.TOP_QUALITY) + .build() + .into(new CustomTarget(bigNotificationImageSize, bigNotificationImageSize) { + @Override + public void onResourceReady(@NonNull BitmapPaletteWrapper resource, Transition glideAnimation) { + Palette palette = resource.getPalette(); + update(resource.getBitmap(), palette.getVibrantColor(palette.getMutedColor(Color.TRANSPARENT))); + } + + @Override + public void onLoadFailed(Drawable drawable) { + update(null, Color.TRANSPARENT); + } + + @Override + public void onLoadCleared(Drawable drawable) { + update(null, Color.TRANSPARENT); + } + + void update(Bitmap bitmap, int color) { + if (bitmap == null) + bitmap = BitmapFactory.decodeResource(service.getResources(), R.drawable.default_album_art); + NotificationCompat.Action playPauseAction = new NotificationCompat.Action(playButtonResId, + service.getString(R.string.action_play_pause), + retrievePlaybackAction(ACTION_TOGGLE)); + NotificationCompat.Action previousAction = new NotificationCompat.Action(R.drawable.ic_skip_previous_white_24dp, + service.getString(R.string.action_previous), + retrievePlaybackAction(ACTION_REWIND)); + NotificationCompat.Action nextAction = new NotificationCompat.Action(R.drawable.ic_skip_next_white_24dp, + service.getString(R.string.action_next), + retrievePlaybackAction(ACTION_SKIP)); + NotificationCompat.Builder builder = new NotificationCompat.Builder(service, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setSubText(song.getAlbumName()) + .setLargeIcon(bitmap) + .setContentIntent(clickIntent) + .setDeleteIntent(deleteIntent) + .setContentTitle(song.getTitle()) + .setContentText(song.getArtistName()) + .setOngoing(isPlaying) + .setShowWhen(false) + .addAction(previousAction) + .addAction(playPauseAction) + .addAction(nextAction); + + builder.setStyle(new androidx.media.app.NotificationCompat.MediaStyle().setMediaSession(service.getMediaSession().getSessionToken()) + .setShowActionsInCompactView(0, 1, 2)) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setColor(color); + + // notification has been stopped before loading was finished + if (stopped) return; + + updateNotifyModeAndPostNotification(builder.build()); + } + })); + } + + public synchronized void stop() { + stopped = true; + service.stopForeground(true); + notificationManager.cancel(NOTIFICATION_ID); + } + + private PendingIntent retrievePlaybackAction(final String action) { + final ComponentName serviceName = new ComponentName(service, MusicService.class); + Intent intent = new Intent(action); + intent.setComponent(serviceName); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + return PendingIntent.getService(service, 0, intent, 0); + } + + void updateNotifyModeAndPostNotification(Notification notification) { + int newNotifyMode; + if (service.isPlaying()) { + newNotifyMode = NOTIFY_MODE_FOREGROUND; + } else { + newNotifyMode = NOTIFY_MODE_BACKGROUND; + } + + if (notifyMode != newNotifyMode && newNotifyMode == NOTIFY_MODE_BACKGROUND) { + service.stopForeground(false); + } + + if (newNotifyMode == NOTIFY_MODE_FOREGROUND) { + service.startForeground(NOTIFICATION_ID, notification); + } else if (newNotifyMode == NOTIFY_MODE_BACKGROUND) { + notificationManager.notify(NOTIFICATION_ID, notification); + } + + notifyMode = newNotifyMode; + } + + @RequiresApi(26) + private void createNotificationChannel() { + NotificationChannel notificationChannel = notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID); + if (notificationChannel == null) { + notificationChannel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, service.getString(R.string.playing_notification_name), NotificationManager.IMPORTANCE_LOW); + notificationChannel.setDescription(service.getString(R.string.playing_notification_description)); + notificationChannel.enableLights(false); + notificationChannel.enableVibration(false); + + notificationManager.createNotificationChannel(notificationChannel); + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/service/playback/Playback.java b/app/src/main/java/com/cappielloantonio/play/service/playback/Playback.java new file mode 100644 index 00000000..7cb48bcb --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/service/playback/Playback.java @@ -0,0 +1,39 @@ +package com.cappielloantonio.play.service.playback; + +import com.cappielloantonio.play.model.Song; + +public interface Playback { + void setDataSource(Song song); + + void queueDataSource(Song song); + + void setCallbacks(PlaybackCallbacks callbacks); + + boolean isReady(); + + boolean isPlaying(); + + void start(); + + void pause(); + + void stop(); + + int getProgress(); + + int getDuration(); + + void setProgress(int progress); + + void setVolume(int volume); + + int getVolume(); + + interface PlaybackCallbacks { + void onTrackStarted(); + + void onTrackWentToNext(); + + void onTrackEnded(); + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/ui/activities/MainActivity.java b/app/src/main/java/com/cappielloantonio/play/ui/activities/MainActivity.java index 8ddc16a9..ccab8556 100644 --- a/app/src/main/java/com/cappielloantonio/play/ui/activities/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/play/ui/activities/MainActivity.java @@ -135,7 +135,7 @@ public class MainActivity extends BaseActivity { * lo chiudo */ navController.addOnDestinationChangedListener((controller, destination, arguments) -> { - if(bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED && ( + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED && ( destination.getId() == R.id.homeFragment || destination.getId() == R.id.libraryFragment || destination.getId() == R.id.searchFragment || @@ -164,32 +164,32 @@ public class MainActivity extends BaseActivity { } private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback = - new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull View view, int state) { - switch (state) { - case BottomSheetBehavior.STATE_SETTLING: - PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); - if(playerBottomSheetFragment == null) break; + new BottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged(@NonNull View view, int state) { + switch (state) { + case BottomSheetBehavior.STATE_SETTLING: + PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); + if (playerBottomSheetFragment == null) break; - playerBottomSheetFragment.scrollOnTop(); - break; - case BottomSheetBehavior.STATE_HIDDEN: - mainViewModel.deleteQueue(); - break; + playerBottomSheetFragment.scrollOnTop(); + break; + case BottomSheetBehavior.STATE_HIDDEN: + mainViewModel.deleteQueue(); + break; + } } - } - @Override - public void onSlide(@NonNull View view, float slideOffset) { - PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); - if(playerBottomSheetFragment == null) return; + @Override + public void onSlide(@NonNull View view, float slideOffset) { + PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); + if (playerBottomSheetFragment == null) return; - float condensedSlideOffset = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.2f)) / 0.2f; - playerBottomSheetFragment.getPlayerHeader().setAlpha(1 - condensedSlideOffset); - playerBottomSheetFragment.getPlayerHeader().setVisibility(condensedSlideOffset > 0.99 ? View.GONE : View.VISIBLE); - } - }; + float condensedSlideOffset = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.2f)) / 0.2f; + playerBottomSheetFragment.getPlayerHeader().setAlpha(1 - condensedSlideOffset); + playerBottomSheetFragment.getPlayerHeader().setVisibility(condensedSlideOffset > 0.99 ? View.GONE : View.VISIBLE); + } + }; /* * Scroll on top del bottom sheet quando chiudo @@ -197,7 +197,7 @@ public class MainActivity extends BaseActivity { */ public void setBottomSheetMusicInfo(Song song) { PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); - if(playerBottomSheetFragment == null) return; + if (playerBottomSheetFragment == null) return; playerBottomSheetFragment.scrollPager(song, 0, false); } @@ -246,7 +246,7 @@ public class MainActivity extends BaseActivity { @Override public void onBackPressed() { - if(bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); else super.onBackPressed(); diff --git a/app/src/main/java/com/cappielloantonio/play/ui/fragment/PlayerBottomSheetFragment.java b/app/src/main/java/com/cappielloantonio/play/ui/fragment/PlayerBottomSheetFragment.java index ca6f9fcf..2e958ff7 100644 --- a/app/src/main/java/com/cappielloantonio/play/ui/fragment/PlayerBottomSheetFragment.java +++ b/app/src/main/java/com/cappielloantonio/play/ui/fragment/PlayerBottomSheetFragment.java @@ -5,7 +5,6 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ScrollView; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -20,7 +19,6 @@ import com.cappielloantonio.play.adapter.PlayerSongQueueAdapter; import com.cappielloantonio.play.databinding.FragmentPlayerBottomSheetBinding; import com.cappielloantonio.play.model.Song; import com.cappielloantonio.play.ui.activities.MainActivity; -import com.cappielloantonio.play.util.MusicUtil; import com.cappielloantonio.play.viewmodel.PlayerBottomSheetViewModel; public class PlayerBottomSheetFragment extends Fragment { @@ -98,7 +96,7 @@ public class PlayerBottomSheetFragment extends Fragment { } private void playSong(Song song) { - Toast.makeText(activity, MusicUtil.getSongFileUri(song), Toast.LENGTH_SHORT).show(); + // Toast.makeText(activity, MusicUtil.getSongFileUri(song), Toast.LENGTH_SHORT).show(); } public View getPlayerHeader() { diff --git a/app/src/main/java/com/cappielloantonio/play/util/PreferenceUtil.java b/app/src/main/java/com/cappielloantonio/play/util/PreferenceUtil.java index c101408a..a00bfbf2 100644 --- a/app/src/main/java/com/cappielloantonio/play/util/PreferenceUtil.java +++ b/app/src/main/java/com/cappielloantonio/play/util/PreferenceUtil.java @@ -19,18 +19,24 @@ public class PreferenceUtil { public static final String TOKEN = "token"; public static final String MUSIC_LIBRARY_ID = "music_library_id"; + public static final String SHUFFLE = "shuffle"; + public static final String REPEAT = "repeat"; + public static final String POSITION = "position"; + public static final String PROGRESS = "progress"; + public static final String SYNC = "sync"; public static final String SONG_GENRE_SYNC = "song_genre_sync"; public static final String HOST_URL = "host"; public static final String IMAGE_CACHE_SIZE = "image_cache_size"; + public static final String MEDIA_CACHE_SIZE = "media_cache_size"; public static final String TRANSCODE_CODEC = "transcode_codec"; public static final String DIRECT_PLAY_CODECS = "direct_play_codecs"; public static final String MAXIMUM_BITRATE = "maximum_bitrate"; + public static final String AUDIO_DUCKING = "audio_ducking"; private static PreferenceUtil sInstance; - private final SharedPreferences mPreferences; private PreferenceUtil(final Context context) { @@ -156,4 +162,21 @@ public class PreferenceUtil { editor.putStringSet(DIRECT_PLAY_CODECS, codecNames); editor.apply(); } + + public final int getMediaCacheSize() { + return Integer.parseInt(mPreferences.getString(MEDIA_CACHE_SIZE, "400000000")); + } + + public final boolean getAudioDucking() { + return mPreferences.getBoolean(AUDIO_DUCKING, true); + } + + + public void registerOnSharedPreferenceChangedListener(SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener) { + mPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); + } + + public void unregisterOnSharedPreferenceChangedListener(SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener) { + mPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notification.png b/app/src/main/res/drawable/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..a401d50f33cf635410a2871597e3623765335245 GIT binary patch literal 2911 zcmZ`*3pkT)AAfAj%%PSX!pp3Z4z^*)%poQ!hvm?V^u8M#CN^zjlVYSorIN~dQdEWx zB2m4RSEU@1NMGa-g)hn>-bTI+Z|!r{b6wBj{{4Ty`+vCa|8+l!L>C7|m<9|00L5*N z1UJ!}B)*{#(X%&=vsv_#V!1ik0ws-FgQ9_4n4>of0OXa$w-k_(r49hnNScQi+soMr z=g$a5ktqy6DvBE#CSn5so{JNGhEmyN1UEE<&cbmmk@F0k=vxe}U&zFkU&<){-?It}XNJt@MDa&cL#UxtI-4cZ8UECrsJ7pQ z{}&{e9Jb(WzK9FC;l+{T+^8%@NVvEJo^%@9$`C)F&{FtEMHW7E25W%X>|tbt!(efyqD5H3k%&NSv13xn zYzEVV!3eRM&mvSjL>QwMm6G;FDIP8EB8jeAXxVvG)U#GFk)-EXk@IZh;!YCOG&ptfwWK%dYIQNyXEpoU zDhx{$rXS?i{cfX}1PLTkQZZ70%O;I60poAUchp9(2L+W;m4$-QVzl|-;Mc<8eJ{x35G43?>|+}W!f5%b1O>w4kYR+u;c&7cpz{-YV3l@_-ruD<)Vgmb9qTg? z8sF`wGzGaB*H3D-PbEQh<(j)DeNR9&m-oP`=#$Ct6o94dff|@mBzUDlPhLi;^McBz zvBI3{Xtl^XY^H+ECkp0NU9_4}6!1wi)Me;e*xW2+B(H|VqS_Fe+ip0{?DPfS$mIjH=9zu#)Izi?@EILV`uGOG5%N$SIu(5d^>$o>hW$%rLo=T>DL5M-Q!YI17|jwl%F%e zVjfxOp3PB;T^@mYrC5J+%Zel1Z=dqk7gt{V zN2z5bXN_(U?v;#hy6RM%+J$i~7Ghl)@NT;ik<O8A)<1r$(v3+3UD!HJN}15&b6kll;pyaZ?sGmjT@f>nC;Bi`GBL z-*!~MLW9Egx)y*0zMgsS*!PmU?Ez@Z$Ksk2RKbW_=T4!Syi5Ja4#4t&Gt&*sN!%{| zUMSZnldF=*l_NUrP3#UCti1zVFDY@D4c_CtqvLBpLn72l+RIr63vKW45nRbX*6jiK zoXFWf{Cakc=JApE@YbuXGWg3NBFU1w=S*gqs8<4DG7lE-v{a$+9dSjo}@@?TWv_rG8M z5pnpn+zV=;0cs&1?$88kPYrJ?UpXo+i@7RTO8iW_V5Xecnc6dFQGfCs;*zVW=JSzPc zIMcw@^@6M|d245rgX)p!nzw((E0sh6TDLFF`JKL1)vJ+^F0bST^)b$=Px-Rr+{Epk z(ejt=A*nk*_p37RKjd>FuzIYLKVeSpz9&?=x&~LdSH!KOy4aO@lW&IO^Mmi)zP@i3 zp%z1TB;l+^uc>L&`-h)hU)^x-jp^8=7mO*6Q-Y|;`EqkUex)k(Ew zMa9*uIbr=u5?|L$j@b6P${;&NplSpev$2VXuApfMBaTe|6VOxg4y=K1x!@=2$N z;wR}3178ocM)STfqngsbH9!jZ+CufY3hDRzXYZzK58%?H3k%F10R;&f;Bt+OuHtFB z*>q`z_SzQvBMF)M4moE%?A%P)&%bI9w_-DUd^+)$9bZ<^4X+o=7VD=(wEKd$sKwo@ zrl9+tl|{H72xsheN;I7<8;Z_?E`vAuB%}yUPR+zfUG3NHQtaMHDn06^{GrUoD?@6+ zIK@;`FYeqmVTu;q6ut@EbZAqQ)TlDLeb3ovrG?{1jvd$SQ)+oB(DgQToT%h?xoCop z*8D?)H(R86SEgDgax@QJYe{JH1K@3-_J}xz-d&Vq6Yt>69M+X;!0_}3!0u_u^U&ND(n + + diff --git a/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml b/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml new file mode 100644 index 00000000..69301a3c --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_next_white_24dp.xml b/app/src/main/res/drawable/ic_skip_next_white_24dp.xml new file mode 100644 index 00000000..473f8513 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_previous_white_24dp.xml b/app/src/main/res/drawable/ic_skip_previous_white_24dp.xml new file mode 100644 index 00000000..487947c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 96d326fc..d1febe89 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,4 +12,7 @@ 56dp 64dp + + 128dp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b6ac8e5..6631ddde 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,7 +29,22 @@ Disable Ignore - - Hello blank fragment + Playback + Download + Couldn\'t play this song. + Playlist is empty + Audio focus denied. + + Play next + Play + Play/Pause + Previous + Next + Add to queue + Remove from queue + Add to playlist + + The playing notification provides actions for play/pause etc. + Playing Notification \ No newline at end of file