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. * 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 * 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... * 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 * @author OxygenCobalt
*/ */
class PlaybackStateDatabase(context: Context) : class PlaybackStateDatabase(context: Context) :

View file

@ -19,6 +19,7 @@ import org.oxycblt.auxio.ui.setupAlbumActions
/** /**
* The [DetailFragment] for an artist. * The [DetailFragment] for an artist.
* TODO: Show a list of songs?
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ArtistDetailFragment : DetailFragment() { 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. * A Base [Fragment] implementing the base features shared across all detail fragments.
* TODO: Add custom artist images * TODO: Add custom artist images
* TODO: Add playing item highlighting
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class DetailFragment : Fragment() { 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.BaseModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback 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.BaseViewHolder
import org.oxycblt.auxio.recycler.viewholders.Highlightable
import org.oxycblt.auxio.ui.accent import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.disable import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setTextColorResource 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.Artist
import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.recycler.DiffCallback 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.BaseViewHolder
import org.oxycblt.auxio.recycler.viewholders.Highlightable
import org.oxycblt.auxio.ui.accent import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.disable import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setTextColorResource 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.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback 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.BaseViewHolder
import org.oxycblt.auxio.recycler.viewholders.Highlightable
import org.oxycblt.auxio.ui.accent import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.disable import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setTextColorResource 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 * A [Fragment] that shows a custom list of [Genre], [Artist], or [Album] data. Also allows for
* search functionality. * search functionality.
* FIXME: Heisenleak when navving from search * FIXME: Heisenleak when navving from search
* FIXME: Heisen on older versions * FIXME: Heisenleak on older versions
*/ */
class LibraryFragment : Fragment(), SearchView.OnQueryTextListener { 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.Genre
import org.oxycblt.auxio.music.Song 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. * 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.** * **Do not use this on the UI elements, instead use the Binding Adapters.**

View file

@ -1,5 +1,6 @@
package org.oxycblt.auxio.playback package org.oxycblt.auxio.playback
import android.animation.ValueAnimator
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
@ -15,7 +16,11 @@ import android.os.Parcelable
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.animation.addListener
import androidx.core.app.NotificationCompat 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.C
import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.MediaItem 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 notificationManager: NotificationManager
private lateinit var notification: NotificationCompat.Builder private lateinit var notification: NotificationCompat.Builder
private var changeIsFromAudioFocus = true private lateinit var audioFocusManager: AudioFocusManager
private var isForeground = false private var isForeground = false
private val serviceJob = Job() private val serviceJob = Job()
@ -103,13 +108,15 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
// Set up AudioFocus/AudioAttributes // Set up AudioFocus/AudioAttributes
player.setAudioAttributes( player.setAudioAttributes(
audioAttributes, settingsManager.doAudioFocus audioAttributes, false
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
player.experimentalSetOffloadSchedulingEnabled(true) player.experimentalSetOffloadSchedulingEnabled(true)
} }
audioFocusManager = AudioFocusManager()
// --- SYSTEM RECEIVER SETUP --- // --- SYSTEM RECEIVER SETUP ---
// Set up the media button callbacks // 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 // Release everything that could cause a memory leak if left around
player.release() player.release()
mediaSession.release() mediaSession.release()
audioFocusManager.destroy()
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
settingsManager.removeCallback(this) settingsManager.removeCallback(this)
// The service coroutines last job is to save the state to the DB, before terminating itself.
serviceScope.launch { serviceScope.launch {
playbackManager.saveStateToDatabase(this@PlaybackService) playbackManager.saveStateToDatabase(this@PlaybackService)
serviceJob.cancel() serviceJob.cancel()
@ -187,8 +196,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
// --- 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) {
@ -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) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
// If the song loops while in the LOOP_ONCE mode, then stop looping after that. // If the song loops while in the LOOP_ONCE mode, then stop looping after that.
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT && if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT &&
@ -260,11 +257,10 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
override fun onPlayingUpdate(isPlaying: Boolean) { override fun onPlayingUpdate(isPlaying: Boolean) {
changeIsFromAudioFocus = false
if (isPlaying && !player.isPlaying) { if (isPlaying && !player.isPlaying) {
player.play() player.play()
notification.updatePlaying(this) notification.updatePlaying(this)
audioFocusManager.requestFocus()
startForegroundOrNotify("Play") startForegroundOrNotify("Play")
startPollingPosition() startPollingPosition()
@ -276,8 +272,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
override fun onLoopUpdate(mode: LoopMode) { override fun onLoopUpdate(mode: LoopMode) {
changeIsFromAudioFocus = false
when (mode) { when (mode) {
LoopMode.NONE -> { LoopMode.NONE -> {
player.repeatMode = Player.REPEAT_MODE_OFF player.repeatMode = Player.REPEAT_MODE_OFF
@ -300,8 +294,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
override fun onSeekConfirm(position: Long) { override fun onSeekConfirm(position: Long) {
changeIsFromAudioFocus = false
player.seekTo(position) player.seekTo(position)
} }
@ -327,13 +319,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
startForegroundOrNotify("Notif action update") startForegroundOrNotify("Notif action update")
} }
override fun onAudioFocusUpdate(doAudioFocus: Boolean) {
player.setAudioAttributes(
audioAttributes,
doAudioFocus
)
}
// --- OTHER FUNCTIONS --- // --- OTHER FUNCTIONS ---
/** /**
@ -517,6 +502,76 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
return false 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. * 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 { companion object {
private const val DISCONNECTED = 0 private const val DISCONNECTED = 0
private const val CONNECTED = 1 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 * 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. * 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 * @author OxygenCobalt
*/ */
class SettingsManager private constructor(context: Context) : class SettingsManager private constructor(context: Context) :
@ -165,10 +165,6 @@ class SettingsManager private constructor(context: Context) :
Keys.KEY_LIBRARY_DISPLAY_MODE -> callbacks.forEach { Keys.KEY_LIBRARY_DISPLAY_MODE -> callbacks.forEach {
it.onLibDisplayModeUpdate(libraryDisplayMode) 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 * [SharedPreferences.OnSharedPreferenceChangeListener] if possible, as it doesn't require a
* context. * context.
*/ */
@ -256,6 +252,5 @@ class SettingsManager private constructor(context: Context) :
fun onColorizeNotifUpdate(doColorize: Boolean) {} fun onColorizeNotifUpdate(doColorize: Boolean) {}
fun onNotifActionUpdate(useAltAction: Boolean) {} fun onNotifActionUpdate(useAltAction: Boolean) {}
fun onLibDisplayModeUpdate(displayMode: DisplayMode) {} fun onLibDisplayModeUpdate(displayMode: DisplayMode) {}
fun onAudioFocusUpdate(doAudioFocus: Boolean) {}
} }
} }