diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d858553c4..f422c3a12 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ @@ -26,7 +27,8 @@ () val songs: List get() = mSongs + var loaded = false + private set + // Load/Sort the entire library. // ONLY CALL THIS FROM AN IO THREAD. fun load(app: Application): MusicLoaderResponse { @@ -69,6 +72,10 @@ class MusicStore private constructor() { ) } + if (loader.response == MusicLoaderResponse.DONE) { + loaded = true + } + return loader.response } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/NotificationUtils.kt b/app/src/main/java/org/oxycblt/auxio/playback/NotificationUtils.kt new file mode 100644 index 000000000..03ccb1ece --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/NotificationUtils.kt @@ -0,0 +1,152 @@ +package org.oxycblt.auxio.playback + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.support.v4.media.session.MediaSessionCompat +import androidx.core.app.NotificationCompat +import androidx.media.app.NotificationCompat.MediaStyle +import org.oxycblt.auxio.MainActivity +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.coil.getBitmap +import org.oxycblt.auxio.playback.state.LoopMode +import org.oxycblt.auxio.playback.state.PlaybackStateManager + +object NotificationUtils { + const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK" + const val NOTIFICATION_ID = 0xA0A0 + const val REQUEST_CODE = 0xA0C0 + + const val ACTION_LOOP = "ACTION_AUXIO_LOOP" + const val ACTION_SKIP_PREV = "ACTION_AUXIO_SKIP_PREV" + const val ACTION_PLAY_PAUSE = "ACTION_AUXIO_PLAY_PAUSE" + const val ACTION_SKIP_NEXT = "ACTION_AUXIO_SKIP_NEXT" + const val ACTION_SHUFFLE = "ACTION_AUXIO_SHUFFLE" +} + +fun NotificationManager.createMediaNotification( + context: Context, + mediaSession: MediaSessionCompat +): NotificationCompat.Builder { + + // Create a notification channel if required + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NotificationUtils.CHANNEL_ID, + context.getString(R.string.label_notification_playback), + NotificationManager.IMPORTANCE_DEFAULT + ) + + createNotificationChannel(channel) + } + + val mainIntent = PendingIntent.getActivity( + context, NotificationUtils.REQUEST_CODE, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT + ) + + // TODO: It would be cool if the notification intent took you to the now playing screen. + return NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_song) + .setStyle( + MediaStyle() + .setMediaSession(mediaSession.sessionToken) + .setShowActionsInCompactView(1, 2, 3) + ) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setChannelId(NotificationUtils.CHANNEL_ID) + .setShowWhen(false) + .setTicker(context.getString(R.string.title_playback)) + .addAction(newAction(NotificationUtils.ACTION_LOOP, context)) + .addAction(newAction(NotificationUtils.ACTION_SKIP_PREV, context)) + .addAction(newAction(NotificationUtils.ACTION_PLAY_PAUSE, context)) + .addAction(newAction(NotificationUtils.ACTION_SKIP_NEXT, context)) + .addAction(newAction(NotificationUtils.ACTION_SHUFFLE, context)) + .setSubText(context.getString(R.string.title_playback)) + .setContentIntent(mainIntent) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) +} + +fun NotificationCompat.Builder.setMetadata(song: Song, context: Context, onDone: () -> Unit) { + setContentTitle(song.name) + setContentText( + song.album.artist.name, + ) + + getBitmap(song, context) { + setLargeIcon(it) + + onDone() + } +} + +@SuppressLint("RestrictedApi") +fun NotificationCompat.Builder.updateLoop(context: Context) { + mActions[0] = newAction(NotificationUtils.ACTION_LOOP, context) +} + +@SuppressLint("RestrictedApi") +fun NotificationCompat.Builder.updatePlaying(context: Context) { + mActions[2] = newAction(NotificationUtils.ACTION_PLAY_PAUSE, context) +} + +@SuppressLint("RestrictedApi") +fun NotificationCompat.Builder.updateShuffle(context: Context) { + mActions[4] = newAction(NotificationUtils.ACTION_SHUFFLE, context) +} + +private fun newAction(action: String, context: Context): NotificationCompat.Action { + val playbackManager = PlaybackStateManager.getInstance() + + val drawable = when (action) { + NotificationUtils.ACTION_LOOP -> { + when (playbackManager.loopMode) { + LoopMode.NONE -> R.drawable.ic_loop_disabled + LoopMode.ONCE -> R.drawable.ic_loop_one + LoopMode.INFINITE -> R.drawable.ic_loop + } + } + + NotificationUtils.ACTION_SKIP_PREV -> { + R.drawable.ic_skip_prev + } + + NotificationUtils.ACTION_PLAY_PAUSE -> { + if (playbackManager.isPlaying) { + R.drawable.ic_pause + } else { + R.drawable.ic_play + } + } + + NotificationUtils.ACTION_SKIP_NEXT -> { + R.drawable.ic_skip_next + } + + NotificationUtils.ACTION_SHUFFLE -> { + if (playbackManager.isShuffling) { + R.drawable.ic_shuffle + } else { + R.drawable.ic_shuffle_disabled + } + } + + else -> R.drawable.ic_play + } + + return NotificationCompat.Action.Builder( + drawable, action, newPlaybackIntent(action, context) + ).build() +} + +private fun newPlaybackIntent(action: String, context: Context): PendingIntent { + return PendingIntent.getBroadcast( + context, NotificationUtils.REQUEST_CODE, Intent(action), PendingIntent.FLAG_UPDATE_CURRENT + ) +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackNotificationHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackNotificationHolder.kt deleted file mode 100644 index 1d1fb3c7f..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackNotificationHolder.kt +++ /dev/null @@ -1,208 +0,0 @@ -package org.oxycblt.auxio.playback - -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.support.v4.media.session.MediaSessionCompat -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.media.app.NotificationCompat.MediaStyle -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.coil.getBitmap -import org.oxycblt.auxio.playback.state.LoopMode -import org.oxycblt.auxio.playback.state.PlaybackStateManager - -// Holder for the playback notification, should only be used by PlaybackService. -// TODO: You really need to rewrite this class. Christ. -// TODO: Disable skip prev/next buttons when you cant do those actions -// TODO: Implement a way to exit the notification -class PlaybackNotificationHolder { - private lateinit var mNotification: Notification - - private lateinit var notificationManager: NotificationManager - private lateinit var baseNotification: NotificationCompat.Builder - - private val playbackManager = PlaybackStateManager.getInstance() - - private var isForeground = false - - fun init(context: Context, session: MediaSessionCompat, playbackService: PlaybackService) { - // Never run if the notification has already been created - if (!::mNotification.isInitialized) { - notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Create a notification channel if required - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - context.getString(R.string.label_notification_playback), - NotificationManager.IMPORTANCE_DEFAULT - ) - notificationManager.createNotificationChannel(channel) - } - - baseNotification = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_song) - .setStyle( - MediaStyle() - .setMediaSession(session.sessionToken) - .setShowActionsInCompactView(1, 2, 3) - ) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setChannelId(CHANNEL_ID) - .setShowWhen(false) - .setTicker(playbackService.getString(R.string.title_playback)) - .addAction(createAction(ACTION_LOOP, playbackService)) - .addAction(createAction(ACTION_SKIP_PREV, playbackService)) - .addAction(createAction(ACTION_PLAY_PAUSE, playbackService)) - .addAction(createAction(ACTION_SKIP_NEXT, playbackService)) - .addAction(createAction(ACTION_SHUFFLE, playbackService)) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - - mNotification = baseNotification.build() - } - } - - fun setMetadata(song: Song, playbackService: PlaybackService) { - // Set the basic metadata since MediaStyle wont do it yourself. - // Fun Fact: The documentation still says that MediaStyle will handle metadata changes - // from MediaSession, even though it doesn't. Its been 6 years. Fun. - baseNotification - .setContentTitle(song.name) - .setContentText( - playbackService.getString( - R.string.format_info, - song.album.artist.name, - song.album.name - ) - ) - - getBitmap(song, playbackService) { - baseNotification.setLargeIcon(it) - - startForegroundOrNotify(playbackService) - } - } - - @SuppressLint("RestrictedApi") - fun updatePlaying(playbackService: PlaybackService) { - baseNotification.mActions[2] = createAction(ACTION_PLAY_PAUSE, playbackService) - - Log.d(this::class.simpleName, baseNotification.mActions[1].iconCompat?.resId.toString()) - - startForegroundOrNotify(playbackService) - } - - @SuppressLint("RestrictedApi") - fun updateLoop(playbackService: PlaybackService) { - baseNotification.mActions[0] = createAction(ACTION_LOOP, playbackService) - - startForegroundOrNotify(playbackService) - } - - @SuppressLint("RestrictedApi") - fun updateShuffle(playbackService: PlaybackService) { - baseNotification.mActions[4] = createAction(ACTION_SHUFFLE, playbackService) - - startForegroundOrNotify(playbackService) - } - - fun stop(playbackService: PlaybackService) { - playbackService.stopForeground(true) - notificationManager.cancel(NOTIFICATION_ID) - - isForeground = false - } - - private fun createAction(action: String, playbackService: PlaybackService): NotificationCompat.Action { - val drawable = when (action) { - ACTION_LOOP -> { - when (playbackManager.loopMode) { - LoopMode.NONE -> R.drawable.ic_loop_disabled - LoopMode.ONCE -> R.drawable.ic_loop_one - LoopMode.INFINITE -> R.drawable.ic_loop - } - } - - ACTION_SKIP_PREV -> { - R.drawable.ic_skip_prev - } - - ACTION_PLAY_PAUSE -> { - if (playbackManager.isPlaying) { - R.drawable.ic_pause - } else { - R.drawable.ic_play - } - } - - ACTION_SKIP_NEXT -> { - R.drawable.ic_skip_next - } - - ACTION_SHUFFLE -> { - if (playbackManager.isShuffling) { - R.drawable.ic_shuffle - } else { - R.drawable.ic_shuffle_disabled - } - } - - else -> R.drawable.ic_play - } - - return NotificationCompat.Action.Builder( - drawable, action, createPlaybackAction(action, playbackService) - ).build() - } - - private fun createPlaybackAction(action: String, playbackService: PlaybackService): PendingIntent { - val intent = Intent() - intent.action = action - - return PendingIntent.getBroadcast( - playbackService, - REQUEST_CODE, - intent, - PendingIntent.FLAG_UPDATE_CURRENT - ) - } - - private fun startForegroundOrNotify(playbackService: PlaybackService) { - mNotification = baseNotification.build() - - // Start the service in the foreground if haven't already. - if (!isForeground) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - playbackService.startForeground( - NOTIFICATION_ID, mNotification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - ) - } else { - playbackService.startForeground(NOTIFICATION_ID, mNotification) - } - } else { - notificationManager.notify(NOTIFICATION_ID, mNotification) - } - } - - companion object { - const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK" - const val NOTIFICATION_ID = 0xA0A0 - const val REQUEST_CODE = 0xA0C0 - - const val ACTION_LOOP = "ACTION_AUXIO_LOOP" - const val ACTION_SKIP_PREV = "ACTION_AUXIO_SKIP_PREV" - const val ACTION_PLAY_PAUSE = "ACTION_AUXIO_PLAY_PAUSE" - const val ACTION_SKIP_NEXT = "ACTION_AUXIO_SKIP_NEXT" - const val ACTION_SHUFFLE = "ACTION_AUXIO_SHUFFLE" - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt index 22e819133..7ad5bd241 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt @@ -1,11 +1,13 @@ package org.oxycblt.auxio.playback +import android.app.NotificationManager import android.app.Service import android.bluetooth.BluetoothDevice import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.ServiceInfo import android.media.AudioManager import android.os.Build import android.os.IBinder @@ -14,6 +16,7 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.util.Log import android.view.KeyEvent +import androidx.core.app.NotificationCompat import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.MediaItem @@ -47,9 +50,12 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { private val playbackManager = PlaybackStateManager.getInstance() private lateinit var mediaSession: MediaSessionCompat private lateinit var systemReceiver: SystemEventReceiver - private lateinit var notificationHolder: PlaybackNotificationHolder + + private lateinit var notificationManager: NotificationManager + private lateinit var notification: NotificationCompat.Builder private var changeIsFromAudioFocus = true + private var isForeground = false private val serviceJob = Job() private val serviceScope = CoroutineScope( @@ -103,11 +109,12 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { // Set up callback for system events systemReceiver = SystemEventReceiver() IntentFilter().apply { - addAction(PlaybackNotificationHolder.ACTION_LOOP) - addAction(PlaybackNotificationHolder.ACTION_SKIP_PREV) - addAction(PlaybackNotificationHolder.ACTION_PLAY_PAUSE) - addAction(PlaybackNotificationHolder.ACTION_SKIP_NEXT) - addAction(PlaybackNotificationHolder.ACTION_SHUFFLE) + addAction(NotificationUtils.ACTION_LOOP) + addAction(NotificationUtils.ACTION_SKIP_PREV) + addAction(NotificationUtils.ACTION_PLAY_PAUSE) + addAction(NotificationUtils.ACTION_SKIP_NEXT) + addAction(NotificationUtils.ACTION_SHUFFLE) + addAction(BluetoothDevice.ACTION_ACL_CONNECTED) addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) @@ -118,9 +125,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { // --- NOTIFICATION SETUP --- - notificationHolder = PlaybackNotificationHolder() + notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationHolder.init(applicationContext, mediaSession, this) + notification = notificationManager.createMediaNotification(this, mediaSession) // --- PLAYBACKSTATEMANAGER SETUP --- @@ -134,7 +141,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { override fun onDestroy() { super.onDestroy() - notificationHolder.stop(this) + stopForegroundAndNotification() unregisterReceiver(systemReceiver) // Release everything that could cause a memory leak if left around @@ -199,14 +206,16 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { player.play() uploadMetadataToSession(it) - notificationHolder.setMetadata(playbackManager.song!!, this) + notification.setMetadata(playbackManager.song!!, this) { + startForegroundOrNotify() + } return } // Stop playing/the notification if there's nothing to play. player.stop() - notificationHolder.stop(this) + stopForegroundAndNotification() } override fun onPlayingUpdate(isPlaying: Boolean) { @@ -214,18 +223,23 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { if (isPlaying && !player.isPlaying) { player.play() - notificationHolder.updatePlaying(this) + notification.updatePlaying(this) + startForegroundOrNotify() + startPollingPosition() } else { player.pause() - notificationHolder.updatePlaying(this) + + notification.updatePlaying(this) + startForegroundOrNotify() } } override fun onShuffleUpdate(isShuffling: Boolean) { changeIsFromAudioFocus = false - notificationHolder.updateShuffle(this) + notification.updateShuffle(this) + startForegroundOrNotify() } override fun onLoopUpdate(mode: LoopMode) { @@ -240,7 +254,8 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { } } - notificationHolder.updateLoop(this) + notification.updateLoop(this) + startForegroundOrNotify() } override fun onSeekConfirm(position: Long) { @@ -258,7 +273,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { player.prepare() player.seekTo(playbackManager.position) - notificationHolder.setMetadata(it, this) + notification.setMetadata(it, this) { + startForegroundOrNotify() + } } } @@ -297,6 +314,29 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { } } + private fun startForegroundOrNotify() { + // Start the service in the foreground if haven't already. + if (!isForeground) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NotificationUtils.NOTIFICATION_ID, notification.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(NotificationUtils.NOTIFICATION_ID, notification.build()) + } + } else { + notificationManager.notify(NotificationUtils.NOTIFICATION_ID, notification.build()) + } + } + + private fun stopForegroundAndNotification() { + stopForeground(true) + notificationManager.cancel(NotificationUtils.NOTIFICATION_ID) + + isForeground = false + } + // Handle a media button event. private fun handleMediaButtonEvent(event: Intent): Boolean { val item = event @@ -344,13 +384,13 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { action?.let { when (it) { - PlaybackNotificationHolder.ACTION_LOOP -> + NotificationUtils.ACTION_LOOP -> playbackManager.setLoopMode(playbackManager.loopMode.increment()) - PlaybackNotificationHolder.ACTION_SKIP_PREV -> playbackManager.prev() - PlaybackNotificationHolder.ACTION_PLAY_PAUSE -> + NotificationUtils.ACTION_SKIP_PREV -> playbackManager.prev() + NotificationUtils.ACTION_PLAY_PAUSE -> playbackManager.setPlayingStatus(!playbackManager.isPlaying) - PlaybackNotificationHolder.ACTION_SKIP_NEXT -> playbackManager.next() - PlaybackNotificationHolder.ACTION_SHUFFLE -> + NotificationUtils.ACTION_SKIP_NEXT -> playbackManager.next() + NotificationUtils.ACTION_SHUFFLE -> playbackManager.setShuffleStatus(!playbackManager.isShuffling) BluetoothDevice.ACTION_ACL_CONNECTED -> resume()