Add Playback Notification

Add a MediaStyle Notification for music playback.
This commit is contained in:
OxygenCobalt 2020-10-31 11:50:42 -06:00
parent a4f55873ec
commit e00930cc5f
9 changed files with 204 additions and 96 deletions

View file

@ -1,9 +1,11 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.content.Context import android.content.Context
import android.content.Intent
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import org.oxycblt.auxio.playback.PlaybackService
import org.oxycblt.auxio.theme.accent import org.oxycblt.auxio.theme.accent
// FIXME: Fix bug where fast navigation will break the animations and // FIXME: Fix bug where fast navigation will break the animations and
@ -16,4 +18,12 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) {
return super.onCreateView(name, context, attrs) return super.onCreateView(name, context, attrs)
} }
override fun onStart() {
super.onStart()
Intent(this, PlaybackService::class.java).also {
this.startService(it)
}
}
} }

View file

@ -1,6 +1,5 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
@ -16,7 +15,6 @@ import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.library.LibraryFragment import org.oxycblt.auxio.library.LibraryFragment
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackService
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.songs.SongsFragment import org.oxycblt.auxio.songs.SongsFragment
import org.oxycblt.auxio.theme.accent import org.oxycblt.auxio.theme.accent
@ -105,15 +103,6 @@ class MainFragment : Fragment() {
} }
} }
// Start the playback service [If not already]
if (!playbackModel.serviceStarted) {
Intent(requireContext(), PlaybackService::class.java).also {
requireContext().startService(it)
}
playbackModel.setServiceStatus(true)
}
Log.d(this::class.simpleName, "Fragment Created.") Log.d(this::class.simpleName, "Fragment Created.")
return binding.root return binding.root

View file

@ -1,9 +1,11 @@
package org.oxycblt.auxio.music.coil package org.oxycblt.auxio.music.coil
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.widget.ImageView import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap
import androidx.databinding.BindingAdapter import androidx.databinding.BindingAdapter
import coil.Coil import coil.Coil
import coil.request.ImageRequest import coil.request.ImageRequest
@ -129,6 +131,17 @@ fun ImageView.bindGenreImage(genre: Genre) {
Coil.imageLoader(context).enqueue(request) Coil.imageLoader(context).enqueue(request)
} }
// Get a bitmap for a song, onDone will be called when the bitmap is loaded.
fun getBitmap(song: Song, context: Context, onDone: (Bitmap) -> Unit) {
Coil.enqueue(
ImageRequest.Builder(context)
.data(song.album.coverUri)
.error(R.drawable.ic_song)
.target { onDone(it.toBitmap()) }
.build()
)
}
// Get the base request used across the other functions. // Get the base request used across the other functions.
private fun getDefaultRequest(context: Context, imageView: ImageView): ImageRequest.Builder { private fun getDefaultRequest(context: Context, imageView: ImageView): ImageRequest.Builder {
return ImageRequest.Builder(context) return ImageRequest.Builder(context)

View file

@ -138,6 +138,8 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
binding.playbackLoop.imageTintList = accentColor binding.playbackLoop.imageTintList = accentColor
binding.playbackLoop.setImageResource(R.drawable.ic_loop) binding.playbackLoop.setImageResource(R.drawable.ic_loop)
} }
else -> return@observe
} }
} }

View file

@ -0,0 +1,73 @@
package org.oxycblt.auxio.playback
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
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.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.coil.getBitmap
internal class PlaybackNotificationHolder {
private lateinit var mNotification: Notification
private lateinit var notificationManager: NotificationManager
private lateinit var baseNotification: NotificationCompat.Builder
fun init(context: Context, session: MediaSessionCompat) {
// 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_notif_playback),
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}
baseNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_song)
.setStyle(MediaStyle().setMediaSession(session.sessionToken))
.setChannelId(CHANNEL_ID)
.setShowWhen(false)
.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. After 6 years.
baseNotification
.setContentTitle(song.name)
.setContentText(
playbackService.getString(
R.string.format_info,
song.album.artist.name,
song.album.name
)
)
getBitmap(song, playbackService) {
baseNotification.setLargeIcon(it)
mNotification = baseNotification.build()
playbackService.startForeground(NOTIFICATION_ID, mNotification)
}
}
companion object {
const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK"
const val NOTIFICATION_ID = 0xA0A0
}
}

View file

@ -1,8 +1,5 @@
package org.oxycblt.auxio.playback package org.oxycblt.auxio.playback
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -13,11 +10,12 @@ import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.Parcelable import android.os.Parcelable
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.SimpleExoPlayer
@ -32,49 +30,38 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.coil.getBitmap
import org.oxycblt.auxio.music.toURI import org.oxycblt.auxio.music.toURI
import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateCallback import org.oxycblt.auxio.playback.state.PlaybackStateCallback
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
private const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK"
private const val NOTIF_ID = 0xA0A0
private const val CONNECTED = 1
private const val DISCONNECTED = 0
// A Service that manages the single ExoPlayer instance and manages the system-side // A Service that manages the single ExoPlayer instance and manages the system-side
// aspects of playback. // aspects of playback.
class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
// TODO: Use the ExoPlayer queue functionality [To an extent]? Could make things faster.
private val player: SimpleExoPlayer by lazy { private val player: SimpleExoPlayer by lazy {
val p = SimpleExoPlayer.Builder(applicationContext).build() SimpleExoPlayer.Builder(applicationContext).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
p.experimentalSetOffloadSchedulingEnabled(true)
}
p.addListener(this)
p
} }
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSession: MediaSessionCompat
private lateinit var systemReceiver: SystemEventReceiver private lateinit var systemReceiver: SystemEventReceiver
private var changeIsFromSystem = false private val notificationHolder = PlaybackNotificationHolder()
private var changeIsFromAudioFocus = true
private val serviceJob = Job() private val serviceJob = Job()
private val serviceScope = CoroutineScope( private val serviceScope = CoroutineScope(
serviceJob + Dispatchers.Main serviceJob + Dispatchers.Main
) )
private lateinit var notification: Notification
// --- SERVICE OVERRIDES --- // --- SERVICE OVERRIDES ---
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(this::class.simpleName, "Service is active.") Log.d(this::class.simpleName, "Service is active.")
return START_STICKY return START_NOT_STICKY
} }
override fun onBind(intent: Intent): IBinder? = null override fun onBind(intent: Intent): IBinder? = null
@ -82,16 +69,8 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Set up the media button callbacks // --- PLAYER SETUP ---
mediaSession = MediaSessionCompat(this, packageName).apply { player.addListener(this)
isActive = true
}
val connector = MediaSessionConnector(mediaSession)
connector.setPlayer(player)
connector.setMediaButtonEventHandler { _, _, mediaButtonEvent ->
handleMediaButtonEvent(mediaButtonEvent)
}
// Set up AudioFocus/AudioAttributes // Set up AudioFocus/AudioAttributes
player.setAudioAttributes( player.setAudioAttributes(
@ -102,7 +81,31 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
true true
) )
notification = createNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
player.experimentalSetOffloadSchedulingEnabled(true)
}
// --- PLAYBACKSTATEMANAGER SETUP ---
playbackManager.addCallback(this)
if (playbackManager.song != null) {
restorePlayer()
}
// --- SYSTEM RECEIVER SETUP ---
// Set up the media button callbacks
mediaSession = MediaSessionCompat(this, packageName).apply {
isActive = true
MediaSessionConnector(this).apply {
setPlayer(player)
setMediaButtonEventHandler { _, _, mediaButtonEvent ->
handleMediaButtonEvent(mediaButtonEvent)
}
}
}
// Set up callback for system events // Set up callback for system events
systemReceiver = SystemEventReceiver() systemReceiver = SystemEventReceiver()
@ -115,7 +118,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
registerReceiver(systemReceiver, this) registerReceiver(systemReceiver, this)
} }
playbackManager.addCallback(this) // --- NOTIFICATION SETUP ---
notificationHolder.init(applicationContext, mediaSession)
} }
override fun onDestroy() { override fun onDestroy() {
@ -136,6 +141,8 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
// --- PLAYER EVENT LISTENER OVERRIDES --- // --- PLAYER EVENT LISTENER OVERRIDES ---
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
changeIsFromAudioFocus = false
if (state == Player.STATE_ENDED) { if (state == Player.STATE_ENDED) {
playbackManager.next() playbackManager.next()
} else if (state == Player.STATE_READY) { } else if (state == Player.STATE_READY) {
@ -144,13 +151,11 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
// If the change to playing occurred from the system instead of PlaybackStateManager, then // Only sync the playing status with PlaybackStateManager if the change occurred
// sync the playing value to PlaybackStateManager to keep it up ton date. // from an Audio Focus change. Nowhere else.
if (isPlaying != playbackManager.isPlaying && changeIsFromSystem) { if (isPlaying != playbackManager.isPlaying && changeIsFromAudioFocus) {
playbackManager.setPlayingStatus(isPlaying) playbackManager.setPlayingStatus(isPlaying)
} }
changeIsFromSystem = true
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@ -162,30 +167,46 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
} }
override fun onPlayerError(error: ExoPlaybackException) {
// If there's any issue, just go to the next song.
playbackManager.next()
}
override fun onPositionDiscontinuity(reason: Int) {
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
playbackManager.setPosition(player.currentPosition / 1000)
}
}
// --- PLAYBACK STATE CALLBACK OVERRIDES --- // --- PLAYBACK STATE CALLBACK OVERRIDES ---
override fun onSongUpdate(song: Song?) { override fun onSongUpdate(song: Song?) {
changeIsFromSystem = false changeIsFromAudioFocus = false
song?.let { song?.let {
val item = MediaItem.fromUri(it.id.toURI()) val item = MediaItem.fromUri(it.id.toURI())
player.setMediaItem(item) player.setMediaItem(item)
player.prepare() player.prepare()
player.play() player.play()
uploadMetadataToSession(it)
notificationHolder.setMetadata(playbackManager.song!!, this)
return return
} }
// Stop playing/the notification if there's nothing to play.
player.stop() player.stop()
stopForeground(true)
} }
override fun onPlayingUpdate(isPlaying: Boolean) { override fun onPlayingUpdate(isPlaying: Boolean) {
changeIsFromSystem = false changeIsFromAudioFocus = false
if (isPlaying && !player.isPlaying) { if (isPlaying && !player.isPlaying) {
player.play() player.play()
startForeground(NOTIF_ID, notification)
startPollingPosition() startPollingPosition()
} else { } else {
player.pause() player.pause()
@ -196,7 +217,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
override fun onLoopUpdate(mode: LoopMode) { override fun onLoopUpdate(mode: LoopMode) {
changeIsFromSystem = false changeIsFromAudioFocus = false
when (mode) { when (mode) {
LoopMode.NONE -> { LoopMode.NONE -> {
@ -209,16 +230,44 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
override fun onSeekConfirm(position: Long) { override fun onSeekConfirm(position: Long) {
changeIsFromSystem = false changeIsFromAudioFocus = false
player.seekTo(position * 1000) player.seekTo(position * 1000)
} }
// --- OTHER FUNCTIONS --- // --- OTHER FUNCTIONS ---
private fun restorePlayer() {
playbackManager.song?.let {
val item = MediaItem.fromUri(it.id.toURI())
player.setMediaItem(item)
player.prepare()
player.play()
notificationHolder.setMetadata(it, this)
}
}
private fun uploadMetadataToSession(song: Song) {
val builder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.name)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.album.artist.name)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, song.album.artist.name)
.putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, song.album.artist.name)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.album.artist.name)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name)
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
getBitmap(song, this) {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it)
mediaSession.setMetadata(builder.build())
}
}
// Awful Hack to get position polling to work, as exoplayer does not provide any // Awful Hack to get position polling to work, as exoplayer does not provide any
// onPositionChanged callback for some inane reason. // onPositionChanged callback for some inane reason.
// FIXME: There has to be a better way of polling positions. // TODO: MediaSession might have a callback for positions. Idk.
private fun pollCurrentPosition() = flow { private fun pollCurrentPosition() = flow {
while (player.isPlaying) { while (player.isPlaying) {
emit(player.currentPosition) emit(player.currentPosition)
@ -274,35 +323,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
return false return false
} }
// Create a notification
// TODO: Spin this off into its own object!
private fun createNotification(): Notification {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.label_notif_playback),
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}
// TODO: Placeholder, implement proper media controls.
val notif = NotificationCompat.Builder(
applicationContext,
CHANNEL_ID
)
.setSmallIcon(R.drawable.ic_song)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.label_is_playing))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setChannelId(CHANNEL_ID)
.build()
return notif
}
// BroadcastReceiver for receiving system events [E.G Headphones connected/disconnected] // BroadcastReceiver for receiving system events [E.G Headphones connected/disconnected]
private inner class SystemEventReceiver : BroadcastReceiver() { private inner class SystemEventReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@ -348,4 +368,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
} }
} }
companion object {
private const val DISCONNECTED = 0
private const val CONNECTED = 1
}
} }

View file

@ -48,9 +48,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateCallback {
private val mIsSeeking = MutableLiveData(false) private val mIsSeeking = MutableLiveData(false)
val isSeeking: LiveData<Boolean> get() = mIsSeeking val isSeeking: LiveData<Boolean> get() = mIsSeeking
private var mServiceStarted = false
val serviceStarted: Boolean get() = mServiceStarted
val formattedPosition = Transformations.map(mPosition) { val formattedPosition = Transformations.map(mPosition) {
it.toDuration() it.toDuration()
} }
@ -190,10 +187,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateCallback {
mIsSeeking.value = value mIsSeeking.value = value
} }
fun setServiceStatus(value: Boolean) {
mServiceStarted = value
}
// --- OVERRIDES --- // --- OVERRIDES ---
override fun onCleared() { override fun onCleared() {

View file

@ -11,17 +11,19 @@ import org.oxycblt.auxio.music.Song
import kotlin.random.Random import kotlin.random.Random
// The manager of the current playback state [Current Song, Queue, Shuffling] // The manager of the current playback state [Current Song, Queue, Shuffling]
// Never use this for ANYTHING UI related, that's what PlaybackViewModel is for. // This class is for sole use by the classes in /playback/.
// Yes, I know MediaSessionCompat and friends exist, but I like having full control over the // If you want to add system-side things, add to PlaybackService.
// playback state instead of dealing with android's likely buggy code. // If you want to add ui-side things, add to PlaybackViewModel.
class PlaybackStateManager { // [Yes, I know MediaSessionCompat exists, but I like having full control over the
// playback state instead of dealing with android's likely buggy code.]
internal class PlaybackStateManager {
// Playback // Playback
private var mSong: Song? = null private var mSong: Song? = null
set(value) { set(value) {
field = value field = value
callbacks.forEach { it.onSongUpdate(value) } callbacks.forEach { it.onSongUpdate(value) }
} }
private var mPosition: Long = 0 private var mPosition: Long = 0 // TODO: Consider using millis instead of seconds?
set(value) { set(value) {
field = value field = value
callbacks.forEach { it.onPositionUpdate(value) } callbacks.forEach { it.onPositionUpdate(value) }

View file

@ -56,6 +56,7 @@
<string name="placeholder_genre">Unknown Genre</string> <string name="placeholder_genre">Unknown Genre</string>
<string name="placeholder_artist">Unknown Artist</string> <string name="placeholder_artist">Unknown Artist</string>
<string name="placeholder_album">Unknown Album</string> <string name="placeholder_album">Unknown Album</string>
<string name="placeholder_song">Unknown Song</string>
<string name="placeholder_no_date">No Date</string> <string name="placeholder_no_date">No Date</string>
<!-- Format Namespace | Value formatting/plurals --> <!-- Format Namespace | Value formatting/plurals -->