media: unwind tightly bound action handling

This commit is contained in:
Alexander Capehart 2024-08-23 13:55:49 -06:00
parent d91343070a
commit c1e5adbc44
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 127 additions and 118 deletions

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback.service
import android.app.Notification import android.app.Notification
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.session.CommandButton import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultActionFactory import androidx.media3.session.DefaultActionFactory
@ -49,10 +50,12 @@ import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.service.MediaItemBrowser import org.oxycblt.auxio.music.service.MediaItemBrowser
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
import org.oxycblt.auxio.widgets.WidgetComponent
class MediaSessionServiceFragment class MediaSessionServiceFragment
@Inject @Inject
@ -60,6 +63,8 @@ constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val actionHandler: PlaybackActionHandler, private val actionHandler: PlaybackActionHandler,
private val playbackSettings: PlaybackSettings,
private val widgetComponent: WidgetComponent,
private val mediaItemBrowser: MediaItemBrowser, private val mediaItemBrowser: MediaItemBrowser,
exoHolderFactory: ExoPlaybackStateHolder.Factory exoHolderFactory: ExoPlaybackStateHolder.Factory
) : ) :
@ -86,6 +91,7 @@ constructor(
.also { it.setSmallIcon(R.drawable.ic_auxio_24) } .also { it.setSmallIcon(R.drawable.ic_auxio_24) }
private var foregroundListener: ForegroundListener? = null private var foregroundListener: ForegroundListener? = null
lateinit var systemReceiver: SystemPlaybackReceiver
lateinit var mediaSession: MediaLibrarySession lateinit var mediaSession: MediaLibrarySession
private set private set
@ -99,6 +105,10 @@ constructor(
playbackManager.addListener(this) playbackManager.addListener(this)
exoHolder.attach() exoHolder.attach()
actionHandler.attach(this) actionHandler.attach(this)
systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
ContextCompat.registerReceiver(
context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
widgetComponent.attach()
mediaItemBrowser.attach(this) mediaItemBrowser.attach(this)
return mediaSession return mediaSession
} }
@ -142,6 +152,8 @@ constructor(
fun release() { fun release() {
waitJob.cancel() waitJob.cancel()
mediaItemBrowser.release() mediaItemBrowser.release()
context.unregisterReceiver(systemReceiver)
widgetComponent.release()
actionHandler.release() actionHandler.release()
exoHolder.release() exoHolder.release()
playbackManager.removeListener(this) playbackManager.removeListener(this)

View file

@ -18,13 +18,8 @@
package org.oxycblt.auxio.playback.service package org.oxycblt.auxio.playback.service
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Bundle import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.CommandButton import androidx.media3.session.CommandButton
import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommand
@ -39,41 +34,31 @@ import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.auxio.widgets.WidgetProvider
class PlaybackActionHandler class PlaybackActionHandler
@Inject @Inject
constructor( constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings
private val widgetComponent: WidgetComponent
) : PlaybackStateManager.Listener, PlaybackSettings.Listener { ) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
interface Callback { interface Callback {
fun onCustomLayoutChanged(layout: List<CommandButton>) fun onCustomLayoutChanged(layout: List<CommandButton>)
} }
private val systemReceiver =
SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
private var callback: Callback? = null private var callback: Callback? = null
fun attach(callback: Callback) { fun attach(callback: Callback) {
this.callback = callback this.callback = callback
playbackManager.addListener(this) playbackManager.addListener(this)
playbackSettings.registerListener(this) playbackSettings.registerListener(this)
ContextCompat.registerReceiver(
context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
} }
fun release() { fun release() {
callback = null callback = null
playbackManager.removeListener(this) playbackManager.removeListener(this)
playbackSettings.unregisterListener(this) playbackSettings.unregisterListener(this)
context.unregisterReceiver(systemReceiver)
widgetComponent.release()
} }
fun withCommands(commands: SessionCommands) = fun withCommands(commands: SessionCommands) =
@ -178,103 +163,3 @@ object PlaybackActions {
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
} }
/**
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
* active [IntentFilter] to be registered.
*/
class SystemPlaybackReceiver(
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val widgetComponent: WidgetComponent
) : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false
val intentFilter =
IntentFilter().apply {
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(AudioManager.ACTION_HEADSET_PLUG)
addAction(PlaybackActions.ACTION_INC_REPEAT_MODE)
addAction(PlaybackActions.ACTION_INVERT_SHUFFLE)
addAction(PlaybackActions.ACTION_SKIP_PREV)
addAction(PlaybackActions.ACTION_PLAY_PAUSE)
addAction(PlaybackActions.ACTION_SKIP_NEXT)
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
// --- SYSTEM EVENTS ---
// Android has three different ways of handling audio plug events for some reason:
// 1. ACTION_HEADSET_PLUG, which only works with wired headsets
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
// a non-starter since both require me to display a permission prompt
// 3. Some internal framework thing that also handles bluetooth headsets
// Just use ACTION_HEADSET_PLUG.
AudioManager.ACTION_HEADSET_PLUG -> {
logD("Received headset plug event")
when (intent.getIntExtra("state", -1)) {
0 -> pauseFromHeadsetPlug()
1 -> playFromHeadsetPlug()
}
initialHeadsetPlugEventHandled = true
}
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
logD("Received Headset noise event")
pauseFromHeadsetPlug()
}
// --- AUXIO EVENTS ---
PlaybackActions.ACTION_PLAY_PAUSE -> {
logD("Received play event")
playbackManager.playing(!playbackManager.progression.isPlaying)
}
PlaybackActions.ACTION_INC_REPEAT_MODE -> {
logD("Received repeat mode event")
playbackManager.repeatMode(playbackManager.repeatMode.increment())
}
PlaybackActions.ACTION_INVERT_SHUFFLE -> {
logD("Received shuffle event")
playbackManager.shuffled(!playbackManager.isShuffled)
}
PlaybackActions.ACTION_SKIP_PREV -> {
logD("Received skip previous event")
playbackManager.prev()
}
PlaybackActions.ACTION_SKIP_NEXT -> {
logD("Received skip next event")
playbackManager.next()
}
PlaybackActions.ACTION_EXIT -> {
logD("Received exit event")
playbackManager.endSession()
}
WidgetProvider.ACTION_WIDGET_UPDATE -> {
logD("Received widget update event")
widgetComponent.update()
}
}
}
private fun playFromHeadsetPlug() {
// ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached,
// which would result in unexpected playback. Work around it by dropping the first
// call to this function, which should come from that Intent.
if (playbackSettings.headsetAutoplay &&
playbackManager.currentSong != null &&
initialHeadsetPlugEventHandled) {
logD("Device connected, resuming")
playbackManager.playing(true)
}
}
private fun pauseFromHeadsetPlug() {
if (playbackManager.currentSong != null) {
logD("Device disconnected, pausing")
playbackManager.playing(false)
}
}
}

View file

@ -0,0 +1,112 @@
package org.oxycblt.auxio.playback.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.auxio.widgets.WidgetProvider
/**
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
* active [IntentFilter] to be registered.
*/
class SystemPlaybackReceiver(
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val widgetComponent: WidgetComponent
) : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false
val intentFilter =
IntentFilter().apply {
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(AudioManager.ACTION_HEADSET_PLUG)
addAction(PlaybackActions.ACTION_INC_REPEAT_MODE)
addAction(PlaybackActions.ACTION_INVERT_SHUFFLE)
addAction(PlaybackActions.ACTION_SKIP_PREV)
addAction(PlaybackActions.ACTION_PLAY_PAUSE)
addAction(PlaybackActions.ACTION_SKIP_NEXT)
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
// --- SYSTEM EVENTS ---
// Android has three different ways of handling audio plug events for some reason:
// 1. ACTION_HEADSET_PLUG, which only works with wired headsets
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
// a non-starter since both require me to display a permission prompt
// 3. Some internal framework thing that also handles bluetooth headsets
// Just use ACTION_HEADSET_PLUG.
AudioManager.ACTION_HEADSET_PLUG -> {
logD("Received headset plug event")
when (intent.getIntExtra("state", -1)) {
0 -> pauseFromHeadsetPlug()
1 -> playFromHeadsetPlug()
}
initialHeadsetPlugEventHandled = true
}
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
logD("Received Headset noise event")
pauseFromHeadsetPlug()
}
// --- AUXIO EVENTS ---
PlaybackActions.ACTION_PLAY_PAUSE -> {
logD("Received play event")
playbackManager.playing(!playbackManager.progression.isPlaying)
}
PlaybackActions.ACTION_INC_REPEAT_MODE -> {
logD("Received repeat mode event")
playbackManager.repeatMode(playbackManager.repeatMode.increment())
}
PlaybackActions.ACTION_INVERT_SHUFFLE -> {
logD("Received shuffle event")
playbackManager.shuffled(!playbackManager.isShuffled)
}
PlaybackActions.ACTION_SKIP_PREV -> {
logD("Received skip previous event")
playbackManager.prev()
}
PlaybackActions.ACTION_SKIP_NEXT -> {
logD("Received skip next event")
playbackManager.next()
}
PlaybackActions.ACTION_EXIT -> {
logD("Received exit event")
playbackManager.endSession()
}
WidgetProvider.ACTION_WIDGET_UPDATE -> {
logD("Received widget update event")
widgetComponent.update()
}
}
}
private fun playFromHeadsetPlug() {
// ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached,
// which would result in unexpected playback. Work around it by dropping the first
// call to this function, which should come from that Intent.
if (playbackSettings.headsetAutoplay &&
playbackManager.currentSong != null &&
initialHeadsetPlugEventHandled) {
logD("Device connected, resuming")
playbackManager.playing(true)
}
}
private fun pauseFromHeadsetPlug() {
if (playbackManager.currentSong != null) {
logD("Device disconnected, pausing")
playbackManager.playing(false)
}
}
}

View file

@ -57,7 +57,7 @@ constructor(
) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener {
private val widgetProvider = WidgetProvider() private val widgetProvider = WidgetProvider()
init { fun attach() {
playbackManager.addListener(this) playbackManager.addListener(this)
uiSettings.registerListener(this) uiSettings.registerListener(this)
imageSettings.registerListener(this) imageSettings.registerListener(this)
@ -90,7 +90,7 @@ constructor(
} else if (uiSettings.roundMode) { } else if (uiSettings.roundMode) {
// < Android 12, but the user still enabled round mode. // < Android 12, but the user still enabled round mode.
logD("Using default corner radius") logD("Using default corner radius")
context.getDimenPixels(R.dimen.size_corners_medium) context.getDimenPixels(R.dimen.spacing_medium)
} else { } else {
// User did not enable round mode. // User did not enable round mode.
logD("Using no corner radius") logD("Using no corner radius")