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:
parent
dfaffecbd3
commit
5b2e0dc0f4
11 changed files with 96 additions and 41 deletions
|
@ -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) :
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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.**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package org.oxycblt.auxio.recycler
|
||||
package org.oxycblt.auxio.recycler.viewholders
|
||||
|
||||
/**
|
||||
* Interface that allows the highlighting of certain ViewHolders
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue