Rewrite Audio Focus

Switch from the built-in exoplayer AudioFocus to a custom AudioFocus setup that supports ducking and auto-resumes after a short interuption.
This commit is contained in:
OxygenCobalt 2020-12-31 11:30:32 -07:00
parent dfaffecbd3
commit 5b2e0dc0f4
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 96 additions and 41 deletions

View file

@ -13,7 +13,6 @@ import org.oxycblt.auxio.logD
* A SQLite database for managing the persistent playback state and queue.
* Yes, I know androidx has Room which supposedly makes database creation easier, but it also
* has a crippling bug where it will endlessly allocate rows even if you clear the entire db, so...
* TODO: Turn queue loading info flow
* @author OxygenCobalt
*/
class PlaybackStateDatabase(context: Context) :

View file

@ -19,6 +19,7 @@ import org.oxycblt.auxio.ui.setupAlbumActions
/**
* The [DetailFragment] for an artist.
* TODO: Show a list of songs?
* @author OxygenCobalt
*/
class ArtistDetailFragment : DetailFragment() {

View file

@ -19,7 +19,6 @@ import org.oxycblt.auxio.ui.memberBinding
/**
* A Base [Fragment] implementing the base features shared across all detail fragments.
* TODO: Add custom artist images
* TODO: Add playing item highlighting
* @author OxygenCobalt
*/
abstract class DetailFragment : Fragment() {

View file

@ -13,8 +13,8 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.Highlightable
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
import org.oxycblt.auxio.recycler.viewholders.Highlightable
import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setTextColorResource

View file

@ -13,8 +13,8 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.Highlightable
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
import org.oxycblt.auxio.recycler.viewholders.Highlightable
import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setTextColorResource

View file

@ -13,8 +13,8 @@ import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.Highlightable
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
import org.oxycblt.auxio.recycler.viewholders.Highlightable
import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setTextColorResource

View file

@ -42,7 +42,7 @@ import org.oxycblt.auxio.ui.setupSongActions
* A [Fragment] that shows a custom list of [Genre], [Artist], or [Album] data. Also allows for
* search functionality.
* FIXME: Heisenleak when navving from search
* FIXME: Heisen on older versions
* FIXME: Heisenleak on older versions
*/
class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {

View file

@ -14,6 +14,8 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
// TODO: Add option to ignore MediaStore
/**
* Get a bitmap for a song. onDone will be called when the bitmap is loaded.
* **Do not use this on the UI elements, instead use the Binding Adapters.**

View file

@ -1,5 +1,6 @@
package org.oxycblt.auxio.playback
import android.animation.ValueAnimator
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothDevice
@ -15,7 +16,11 @@ import android.os.Parcelable
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
import androidx.core.animation.addListener
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.MediaItem
@ -75,7 +80,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
private lateinit var notificationManager: NotificationManager
private lateinit var notification: NotificationCompat.Builder
private var changeIsFromAudioFocus = true
private lateinit var audioFocusManager: AudioFocusManager
private var isForeground = false
private val serviceJob = Job()
@ -103,13 +108,15 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
// Set up AudioFocus/AudioAttributes
player.setAudioAttributes(
audioAttributes, settingsManager.doAudioFocus
audioAttributes, false
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
player.experimentalSetOffloadSchedulingEnabled(true)
}
audioFocusManager = AudioFocusManager()
// --- SYSTEM RECEIVER SETUP ---
// Set up the media button callbacks
@ -173,9 +180,11 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
// Release everything that could cause a memory leak if left around
player.release()
mediaSession.release()
audioFocusManager.destroy()
playbackManager.removeCallback(this)
settingsManager.removeCallback(this)
// The service coroutines last job is to save the state to the DB, before terminating itself.
serviceScope.launch {
playbackManager.saveStateToDatabase(this@PlaybackService)
serviceJob.cancel()
@ -187,8 +196,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
// --- PLAYER EVENT LISTENER OVERRIDES ---
override fun onPlaybackStateChanged(state: Int) {
changeIsFromAudioFocus = false
if (state == Player.STATE_ENDED) {
playbackManager.next()
} else if (state == Player.STATE_READY) {
@ -196,16 +203,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
// Only sync the playing status with PlaybackStateManager if the change occurred
// from an Audio Focus change. Nowhere else.
if (isPlaying != playbackManager.isPlaying && changeIsFromAudioFocus) {
playbackManager.setPlayingStatus(isPlaying)
}
changeIsFromAudioFocus = true
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
// If the song loops while in the LOOP_ONCE mode, then stop looping after that.
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT &&
@ -260,11 +257,10 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
override fun onPlayingUpdate(isPlaying: Boolean) {
changeIsFromAudioFocus = false
if (isPlaying && !player.isPlaying) {
player.play()
notification.updatePlaying(this)
audioFocusManager.requestFocus()
startForegroundOrNotify("Play")
startPollingPosition()
@ -276,8 +272,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
override fun onLoopUpdate(mode: LoopMode) {
changeIsFromAudioFocus = false
when (mode) {
LoopMode.NONE -> {
player.repeatMode = Player.REPEAT_MODE_OFF
@ -300,8 +294,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
override fun onSeekConfirm(position: Long) {
changeIsFromAudioFocus = false
player.seekTo(position)
}
@ -327,13 +319,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
startForegroundOrNotify("Notif action update")
}
override fun onAudioFocusUpdate(doAudioFocus: Boolean) {
player.setAudioAttributes(
audioAttributes,
doAudioFocus
)
}
// --- OTHER FUNCTIONS ---
/**
@ -517,6 +502,76 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
return false
}
/**
* Object that manages the AudioFocus state.
* Adapted from NewPipe (https://github.com/TeamNewPipe/NewPipe)
*/
inner class AudioFocusManager : AudioManager.OnAudioFocusChangeListener {
private val audioManager = ContextCompat.getSystemService(
this@PlaybackService, AudioManager::class.java
) ?: error("Cannot obtain AudioManager.")
private val request = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
.setWillPauseWhenDucked(true)
.setOnAudioFocusChangeListener(this)
.build()
fun requestFocus() {
AudioManagerCompat.requestAudioFocus(audioManager, request)
}
fun destroy() {
AudioManagerCompat.abandonAudioFocusRequest(audioManager, request)
}
override fun onAudioFocusChange(focusChange: Int) {
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> onGain()
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> onDuck()
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> onLoss()
}
}
private fun onGain() {
if (settingsManager.doAudioFocus) {
if (player.volume == VOLUME_DUCK && playbackManager.isPlaying) {
player.volume = VOLUME_DUCK
animateVolume(VOLUME_DUCK, VOLUME_FULL)
} else {
playbackManager.setPlayingStatus(true)
}
}
}
private fun onLoss() {
if (settingsManager.doAudioFocus) {
playbackManager.setPlayingStatus(false)
}
}
private fun onDuck() {
if (settingsManager.doAudioFocus) {
player.volume = VOLUME_DUCK
}
}
private fun animateVolume(from: Float, to: Float) {
ValueAnimator().apply {
setFloatValues(from, to)
duration = DUCK_DURATION
addListener(
onStart = { player.volume = from },
onCancel = { player.volume = to },
onEnd = { player.volume = to }
)
addUpdateListener {
player.volume = it.animatedValue as Float
}
start()
}
}
}
/**
* A [BroadcastReceiver] for receiving system events from the media notification or the headset.
*/
@ -593,5 +648,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
companion object {
private const val DISCONNECTED = 0
private const val CONNECTED = 1
private const val VOLUME_DUCK = 0.2f
private const val DUCK_DURATION = 1500L
private const val VOLUME_FULL = 1.0f
}
}

View file

@ -1,4 +1,4 @@
package org.oxycblt.auxio.recycler
package org.oxycblt.auxio.recycler.viewholders
/**
* Interface that allows the highlighting of certain ViewHolders

View file

@ -10,7 +10,7 @@ import org.oxycblt.auxio.ui.ACCENTS
/**
* Wrapper around the [SharedPreferences] class that writes & reads values without a context.
* TODO: Add option to play song from genre, now that its possible
* TODO: Add option to hide covers
* @author OxygenCobalt
*/
class SettingsManager private constructor(context: Context) :
@ -165,10 +165,6 @@ class SettingsManager private constructor(context: Context) :
Keys.KEY_LIBRARY_DISPLAY_MODE -> callbacks.forEach {
it.onLibDisplayModeUpdate(libraryDisplayMode)
}
Keys.KEY_AUDIO_FOCUS -> callbacks.forEach {
it.onAudioFocusUpdate(doAudioFocus)
}
}
}
@ -248,7 +244,7 @@ class SettingsManager private constructor(context: Context) :
}
/**
* An safe interface for receiving some preference updates. Use/Extend this instead of
* An interface for receiving some preference updates. Use/Extend this instead of
* [SharedPreferences.OnSharedPreferenceChangeListener] if possible, as it doesn't require a
* context.
*/
@ -256,6 +252,5 @@ class SettingsManager private constructor(context: Context) :
fun onColorizeNotifUpdate(doColorize: Boolean) {}
fun onNotifActionUpdate(useAltAction: Boolean) {}
fun onLibDisplayModeUpdate(displayMode: DisplayMode) {}
fun onAudioFocusUpdate(doAudioFocus: Boolean) {}
}
}