service: break into components

This commit is contained in:
Alexander Capehart 2024-04-11 23:44:35 -06:00
parent 02877972af
commit be23208f72
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
15 changed files with 826 additions and 655 deletions

View file

@ -19,575 +19,72 @@
package org.oxycblt.auxio
import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.database.ContentObserver
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.MediaStore
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSession.ConnectionResult
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import coil.ImageLoader
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.guava.asListenableFuture
import org.oxycblt.auxio.image.service.NeoBitmapLoader
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.service.IndexingNotification
import org.oxycblt.auxio.music.service.MusicMediaItemBrowser
import org.oxycblt.auxio.music.service.ObservingNotification
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.service.ExoPlaybackStateHolder
import org.oxycblt.auxio.playback.service.SystemPlaybackReceiver
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
// TODO: Android Auto Hookup
// TODO: Custom notif
import org.oxycblt.auxio.music.service.IndexingServiceFragment
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
@AndroidEntryPoint
class AuxioService :
MediaLibraryService(),
MediaLibrarySession.Callback,
MusicRepository.IndexingWorker,
MusicRepository.IndexingListener,
MusicRepository.UpdateListener,
MusicSettings.Listener,
PlaybackStateManager.Listener,
PlaybackSettings.Listener {
private val serviceJob = Job()
private var inPlayback = false
class AuxioService : MediaLibraryService(), ForegroundListener {
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
@Inject lateinit var musicRepository: MusicRepository
@Inject lateinit var musicSettings: MusicSettings
private lateinit var indexingNotification: IndexingNotification
private lateinit var observingNotification: ObservingNotification
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var indexerContentObserver: SystemContentObserver
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
@Inject lateinit var playbackManager: PlaybackStateManager
@Inject lateinit var playbackSettings: PlaybackSettings
@Inject lateinit var systemReceiver: SystemPlaybackReceiver
@Inject lateinit var exoHolderFactory: ExoPlaybackStateHolder.Factory
private lateinit var exoHolder: ExoPlaybackStateHolder
@Inject lateinit var bitmapLoader: NeoBitmapLoader
@Inject lateinit var imageLoader: ImageLoader
@Inject lateinit var musicMediaItemBrowser: MusicMediaItemBrowser
private val waitScope = CoroutineScope(serviceJob + Dispatchers.Default)
private lateinit var mediaSession: MediaLibrarySession
@Inject lateinit var indexingFragment: IndexingServiceFragment
@SuppressLint("WrongConstant")
override fun onCreate() {
super.onCreate()
indexingNotification = IndexingNotification(this)
observingNotification = ObservingNotification(this)
wakeLock =
getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
exoHolder = exoHolderFactory.create()
mediaSession =
MediaLibrarySession.Builder(this, exoHolder.mediaSessionPlayer, this)
.setBitmapLoader(bitmapLoader)
.build()
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver()
setMediaNotificationProvider(
DefaultMediaNotificationProvider.Builder(this)
.setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE)
.setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK")
.setChannelName(R.string.lbl_playback)
.build()
.also { it.setSmallIcon(R.drawable.ic_auxio_24) })
addSession(mediaSession)
updateCustomButtons()
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
exoHolder.attach()
playbackManager.addListener(this)
playbackSettings.registerListener(this)
ContextCompat.registerReceiver(
this, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
musicMediaItemBrowser.attach()
musicSettings.registerListener(this)
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this)
musicRepository.registerWorker(this)
mediaSessionFragment.attach(this, this)
indexingFragment.attach(this)
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
if (!playbackManager.progression.isPlaying) {
// Stop the service if not playing, continue playing in the background
// otherwise.
endSession()
}
mediaSessionFragment.handleTaskRemoved()
}
override fun onDestroy() {
super.onDestroy()
// De-initialize core service components first.
serviceJob.cancel()
wakeLock.releaseSafe()
// Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur.
indexerContentObserver.release()
exoHolder.release()
musicSettings.unregisterListener(this)
musicRepository.removeUpdateListener(this)
musicRepository.removeIndexingListener(this)
musicRepository.unregisterWorker(this)
// Pause just in case this destruction was unexpected.
playbackManager.playing(false)
playbackManager.unregisterStateHolder(exoHolder)
playbackSettings.unregisterListener(this)
removeSession(mediaSession)
mediaSession.release()
unregisterReceiver(systemReceiver)
exoHolder.release()
indexingFragment.release()
mediaSessionFragment.release()
}
// --- INDEXER OVERRIDES ---
override fun requestIndex(withCache: Boolean) {
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
// Cancel the previous music loading job.
currentIndexJob?.cancel()
// Start a new music loading job on a co-routine.
currentIndexJob = musicRepository.index(this, withCache)
}
override val workerContext: Context
get() = this
override val scope = indexScope
override fun onIndexingStateChanged() {
updateForeground(forMusic = true)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
savedState.copy(
heap =
savedState.heap.map { song ->
song?.let { deviceLibrary.findSong(it.uid) }
}),
true)
}
}
// --- INTERNAL ---
private fun updateForeground(forMusic: Boolean) {
if (inPlayback) {
if (!forMusic) {
val notification =
mediaNotificationProvider.createNotification(
mediaSession,
mediaSession.customLayout,
mediaNotificationManager.actionFactory) { notification ->
postMediaNotification(notification, mediaSession)
}
postMediaNotification(notification, mediaSession)
}
return
}
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
updateLoadingForeground(state.progress)
} else {
updateIdleForeground()
}
}
private fun updateLoadingForeground(progress: IndexingProgress) {
// When loading, we want to enter the foreground state so that android does
// not shut off the loading process. Note that while we will always post the
// notification when initially starting, we will not update the notification
// unless it indicates that it has changed.
val changed = indexingNotification.updateIndexingState(progress)
if (changed) {
logD("Notification changed, re-posting notification")
startForeground(indexingNotification.code, indexingNotification.build())
}
// Make sure we can keep the CPU on while loading music
wakeLock.acquireSafe()
}
private fun updateIdleForeground() {
if (musicSettings.shouldBeObserving) {
// There are a few reasons why we stay in the foreground with automatic rescanning:
// 1. Newer versions of Android have become more and more restrictive regarding
// how a foreground service starts. Thus, it's best to go foreground now so that
// we can go foreground later.
// 2. If a non-foreground service is killed, the app will probably still be alive,
// and thus the music library will not be updated at all.
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
// this anymore, or at least I only have to use it when the app task is not removed.
logD("Need to observe, staying in foreground")
startForeground(observingNotification.code, observingNotification.build())
} else {
// Not observing and done loading, exit foreground.
logD("Exiting foreground")
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
// Release our wake lock (if we were using it)
wakeLock.releaseSafe()
}
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.acquireSafe() {
// Avoid unnecessary acquire calls.
if (!wakeLock.isHeld) {
logD("Acquiring wake lock")
// Time out after a minute, which is the average music loading time for a medium-sized
// library. If this runs out, we will re-request the lock, and if music loading is
// shorter than the timeout, it will be released early.
acquire(WAKELOCK_TIMEOUT_MS)
}
}
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.releaseSafe() {
// Avoid unnecessary release calls.
if (wakeLock.isHeld) {
logD("Releasing wake lock")
release()
}
}
// --- SETTING CALLBACKS ---
override fun onIndexingSettingChanged() {
// Music loading configuration changed, need to reload music.
requestIndex(true)
}
override fun onObservingChanged() {
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (currentIndexJob == null) {
logD("Not loading, updating idle session")
updateForeground(forMusic = false)
}
}
/**
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
* known to the user as automatic rescanning. The active (and not passive) nature of observing
* the database is what requires [IndexerService] to stay foreground when this is enabled.
*/
private inner class SystemContentObserver :
ContentObserver(Handler(Looper.getMainLooper())), Runnable {
private val handler = Handler(Looper.getMainLooper())
init {
contentResolverSafe.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
}
/**
* Release this instance, preventing it from further observing the database and cancelling
* any pending update events.
*/
fun release() {
handler.removeCallbacks(this)
contentResolverSafe.unregisterContentObserver(this)
}
override fun onChange(selfChange: Boolean) {
// Batch rapid-fire updates to the library into a single call to run after 500ms
handler.removeCallbacks(this)
handler.postDelayed(this, REINDEX_DELAY_MS)
}
override fun run() {
// Check here if we should even start a reindex. This is much less bug-prone than
// registering and de-registering this component as this setting changes.
if (musicSettings.shouldBeObserving) {
logD("MediaStore changed, starting re-index")
requestIndex(true)
}
}
}
// --- SERVICE MANAGEMENT ---
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
mediaSession
mediaSessionFragment.mediaSession
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
logD("Notification update requested")
updateForeground(forMusic = false)
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
private fun postMediaNotification(notification: MediaNotification, session: MediaSession) {
// Pulled from MediaNotificationManager: Need to specify MediaSession token manually
// in notification
val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token
notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
startForeground(notification.notificationId, notification.notification)
}
// --- MEDIASESSION CALLBACKS ---
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): ConnectionResult {
val sessionCommands =
ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
.add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
.add(SessionCommand(ACTION_EXIT, Bundle.EMPTY))
.build()
return ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(sessionCommands)
.build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> =
when (customCommand.customAction) {
ACTION_INC_REPEAT_MODE -> {
logD(playbackManager.repeatMode.increment())
playbackManager.repeatMode(playbackManager.repeatMode.increment())
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
ACTION_INVERT_SHUFFLE -> {
playbackManager.shuffled(!playbackManager.isShuffled)
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
ACTION_EXIT -> {
endSession()
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
else -> super.onCustomCommand(session, controller, customCommand, args)
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> =
Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params))
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> {
val result =
musicMediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(result)
}
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
Futures.immediateFuture(
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val children =
musicMediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError<ImmutableList<MediaItem>>(
LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(children)
}
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: LibraryParams?
): ListenableFuture<LibraryResult<Void>> =
waitScope
.async {
val count = musicMediaItemBrowser.prepareSearch(query)
session.notifySearchResultChanged(browser, query, count, params)
LibraryResult.ofVoid()
}
.asListenableFuture()
override fun onGetSearchResult(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: LibraryParams?
) =
waitScope
.async {
musicMediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
}
.asListenableFuture()
// --- BUTTON MANAGEMENT ---
override fun onPauseOnRepeatChanged() {
super.onPauseOnRepeatChanged()
updateCustomButtons()
}
override fun onProgressionChanged(progression: Progression) {
super.onProgressionChanged(progression)
if (progression.isPlaying) {
inPlayback = true
override fun updateForeground(change: ForegroundListener.Change) {
if (mediaSessionFragment.hasNotification()) {
if (change == ForegroundListener.Change.MEDIA_SESSION) {
mediaSessionFragment.createNotification {
startForeground(it.notificationId, it.notification)
}
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
super.onRepeatModeChanged(repeatMode)
updateCustomButtons()
}
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
super.onQueueReordered(queue, index, isShuffled)
updateCustomButtons()
}
override fun onNotificationActionChanged() {
super.onNotificationActionChanged()
updateCustomButtons()
}
private fun updateCustomButtons() {
val actions = mutableListOf<CommandButton>()
when (playbackSettings.notificationAction) {
ActionMode.REPEAT -> {
actions.add(
CommandButton.Builder()
.setIconResId(playbackManager.repeatMode.icon)
.setDisplayName(getString(R.string.desc_change_repeat))
.setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle()))
.build())
}
ActionMode.SHUFFLE -> {
actions.add(
CommandButton.Builder()
.setIconResId(
if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24
else R.drawable.ic_shuffle_off_24)
.setDisplayName(getString(R.string.lbl_shuffle))
.setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle()))
.build())
}
else -> {}
}
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_close_24)
.setDisplayName(getString(R.string.desc_exit))
.setSessionCommand(SessionCommand(ACTION_EXIT, Bundle()))
.build())
mediaSession.setCustomLayout(actions)
}
private fun endSession() {
// This session has ended, so we need to reset this flag for when the next
// session starts.
exoHolder.save {
// User could feasibly start playing again if they were fast enough, so
// we need to avoid stopping the foreground state if that's the case.
if (playbackManager.progression.isPlaying) {
playbackManager.playing(false)
}
inPlayback = false
updateForeground(forMusic = false)
// Nothing changed, but don't show anything music related since we can always
// index during playback.
} else {
indexingFragment.createNotification {
if (it != null) {
startForeground(it.code, it.build())
} else {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
}
}
companion object {
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
const val REINDEX_DELAY_MS = 500L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
}
}
interface ForegroundListener {
fun updateForeground(change: Change)
enum class Change {
MEDIA_SESSION,
INDEXER
}
}

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.service.MediaSessionUID
class NeoBitmapLoader
class MediaSessionBitmapLoader
@Inject
constructor(
private val musicRepository: MusicRepository,

View file

@ -0,0 +1,184 @@
/*
* Copyright (c) 2024 Auxio Project
* IndexerComponent.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.PowerManager
import coil.ImageLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
class IndexingServiceFragment
@Inject
constructor(
@ApplicationContext override val workerContext: Context,
private val playbackManager: PlaybackStateManager,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings,
private val contentObserver: SystemContentObserver,
private val imageLoader: ImageLoader
) :
MusicRepository.IndexingWorker,
MusicRepository.IndexingListener,
MusicRepository.UpdateListener,
MusicSettings.Listener {
private val indexJob = Job()
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
private val indexingNotification = IndexingNotification(workerContext)
private val observingNotification = ObservingNotification(workerContext)
private var foregroundListener: ForegroundListener? = null
private val wakeLock =
workerContext
.getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
fun attach(listener: ForegroundListener) {
foregroundListener = listener
musicSettings.registerListener(this)
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this)
musicRepository.registerWorker(this)
contentObserver.attach()
}
fun release() {
contentObserver.release()
musicSettings.registerListener(this)
musicRepository.addIndexingListener(this)
musicRepository.addUpdateListener(this)
musicRepository.removeIndexingListener(this)
foregroundListener = null
}
fun createNotification(post: (IndexerNotification?) -> Unit) {
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
// There are a few reasons why we stay in the foreground with automatic rescanning:
// 1. Newer versions of Android have become more and more restrictive regarding
// how a foreground service starts. Thus, it's best to go foreground now so that
// we can go foreground later.
// 2. If a non-foreground service is killed, the app will probably still be alive,
// and thus the music library will not be updated at all.
val changed = indexingNotification.updateIndexingState(state.progress)
if (changed) {
post(indexingNotification)
}
} else if (musicSettings.shouldBeObserving) {
// Not observing and done loading, exit foreground.
logD("Exiting foreground")
post(observingNotification)
} else {
post(null)
}
}
override fun requestIndex(withCache: Boolean) {
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
// Cancel the previous music loading job.
currentIndexJob?.cancel()
// Start a new music loading job on a co-routine.
currentIndexJob = musicRepository.index(this, withCache)
}
override val scope = indexScope
override fun onIndexingStateChanged() {
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
wakeLock.acquireSafe()
} else {
wakeLock.releaseSafe()
}
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
savedState.copy(
heap =
savedState.heap.map { song ->
song?.let { deviceLibrary.findSong(it.uid) }
}),
true)
}
}
override fun onIndexingSettingChanged() {
super.onIndexingSettingChanged()
musicRepository.requestIndex(true)
}
override fun onObservingChanged() {
super.onObservingChanged()
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (currentIndexJob == null) {
logD("Not loading, updating idle session")
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
}
}
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.acquireSafe() {
// Avoid unnecessary acquire calls.
if (!wakeLock.isHeld) {
logD("Acquiring wake lock")
// Time out after a minute, which is the average music loading time for a medium-sized
// library. If this runs out, we will re-request the lock, and if music loading is
// shorter than the timeout, it will be released early.
acquire(WAKELOCK_TIMEOUT_MS)
}
}
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.releaseSafe() {
// Avoid unnecessary release calls.
if (wakeLock.isHeld) {
logD("Releasing wake lock")
release()
}
}
companion object {
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
}
}

View file

@ -20,23 +20,64 @@ package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.SystemClock
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.ui.ForegroundServiceNotification
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* A dynamic [ForegroundServiceNotification] that shows the current music loading state.
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* signal a Service's ongoing foreground state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class IndexerNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
.setLightsEnabled(false)
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
*
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
/**
* Reduced representation of a [NotificationChannelCompat].
*
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
}
/**
* A dynamic [IndexerNotification] that shows the current music loading state.
*
* @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexingNotification(private val context: Context) :
ForegroundServiceNotification(context, indexerChannel) {
IndexerNotification(context, indexerChannel) {
private var lastUpdateTime = -1L
init {
@ -92,13 +133,12 @@ class IndexingNotification(private val context: Context) :
}
/**
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
* monitoring the music library for changes.
* A static [IndexerNotification] that signals to the user that the app is currently monitoring the
* music library for changes.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ObservingNotification(context: Context) :
ForegroundServiceNotification(context, indexerChannel) {
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
init {
setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -116,5 +156,5 @@ class ObservingNotification(context: Context) :
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel =
ForegroundServiceNotification.ChannelInfo(
IndexerNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* MusicMediaItemBrowser.kt is part of Auxio.
* MediaItemBrowser.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.search.SearchEngine
class MusicMediaItemBrowser
class MediaItemBrowser
@Inject
constructor(
@ApplicationContext private val context: Context,
@ -49,19 +49,42 @@ constructor(
private val browserJob = Job()
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
private var invalidator: Invalidator? = null
fun attach() {
interface Invalidator {
fun invalidate(ids: List<String>)
}
fun attach(invalidator: Invalidator) {
this.invalidator = invalidator
musicRepository.addUpdateListener(this)
}
fun release() {
browserJob.cancel()
invalidator = null
musicRepository.removeUpdateListener(this)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary
var invalidateSearch = false
if (changes.deviceLibrary && deviceLibrary != null) {
val ids =
MediaSessionUID.Category.IMPORTANT +
deviceLibrary.albums.map { MediaSessionUID.Single(it.uid) } +
deviceLibrary.artists.map { MediaSessionUID.Single(it.uid) } +
deviceLibrary.genres.map { MediaSessionUID.Single(it.uid) }
invalidator?.invalidate(ids.map { it.toString() })
invalidateSearch = true
}
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
val ids = userLibrary.playlists.map { MediaSessionUID.Single(it.uid) }
invalidator?.invalidate(ids.map { it.toString() })
invalidateSearch = true
}
if (invalidateSearch) {
for (entry in searchResults.entries) {
entry.value.cancel()
}
@ -113,13 +136,7 @@ constructor(
is MediaSessionUID.Category -> {
when (mediaSessionUID) {
MediaSessionUID.Category.ROOT ->
listOf(
MediaSessionUID.Category.SONGS,
MediaSessionUID.Category.ALBUMS,
MediaSessionUID.Category.ARTISTS,
MediaSessionUID.Category.GENRES,
MediaSessionUID.Category.PLAYLISTS)
.map { it.toMediaItem(context) }
MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) }
MediaSessionUID.Category.SONGS ->
deviceLibrary.songs.map { it.toMediaItem(context, null) }
MediaSessionUID.Category.ALBUMS ->

View file

@ -215,6 +215,10 @@ sealed interface MediaSessionUID {
PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
override fun toString() = "$ID_CATEGORY:$id"
companion object {
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
}
}
data class Single(val uid: Music.UID) : MediaSessionUID {

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 Auxio Project
* SystemContentObserver.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.service
import android.content.Context
import android.database.ContentObserver
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.util.logD
/**
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior known
* to the user as automatic rescanning. The active (and not passive) nature of observing the
* database is what requires [IndexerService] to stay foreground when this is enabled.
*/
class SystemContentObserver
@Inject
constructor(
@ApplicationContext private val context: Context,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
) : ContentObserver(Handler(Looper.getMainLooper())), Runnable {
private val handler = Handler(Looper.getMainLooper())
fun attach() {
context.contentResolverSafe.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
}
/**
* Release this instance, preventing it from further observing the database and cancelling any
* pending update events.
*/
fun release() {
handler.removeCallbacks(this)
context.contentResolverSafe.unregisterContentObserver(this)
}
override fun onChange(selfChange: Boolean) {
// Batch rapid-fire updates to the library into a single call to run after 500ms
handler.removeCallbacks(this)
handler.postDelayed(this, REINDEX_DELAY_MS)
}
override fun run() {
// Check here if we should even start a reindex. This is much less bug-prone than
// registering and de-registering this component as this setting changes.
if (musicSettings.shouldBeObserving) {
logD("MediaStore changed, starting re-index")
musicRepository.requestIndex(true)
}
}
private companion object {
const val REINDEX_DELAY_MS = 500L
}
}

View file

@ -81,6 +81,9 @@ class ExoPlaybackStateHolder(
private var currentSaveJob: Job? = null
private var openAudioEffectSession = false
var sessionOngoing = false
private set
fun attach() {
player.addListener(this)
playbackManager.registerStateHolder(this)
@ -358,6 +361,20 @@ class ExoPlaybackStateHolder(
ack?.let { playbackManager.ack(this, it) }
}
override fun endSession() {
// This session has ended, so we need to reset this flag for when the next
// session starts.
save {
// User could feasibly start playing again if they were fast enough, so
// we need to avoid stopping the foreground state if that's the case.
if (playbackManager.progression.isPlaying) {
playbackManager.playing(false)
}
sessionOngoing = false
playbackManager.ack(this, StateAck.SessionEnded)
}
}
override fun reset(ack: StateAck.NewPlayback) {
player.setMediaItems(listOf())
playbackManager.ack(this, ack)
@ -372,6 +389,7 @@ class ExoPlaybackStateHolder(
if (player.playWhenReady) {
// Mark that we have started playing so that the notification can now be posted.
logD("Player has started playing")
sessionOngoing = true
if (!openAudioEffectSession) {
// Convention to start an audioeffect session on play/pause rather than
// start/stop

View file

@ -50,12 +50,12 @@ import org.oxycblt.auxio.util.logE
/**
* A thin wrapper around the player instance that drastically reduces the command surface and
* forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands
* that Media3 will throw at me will be handled in a predictable way, rather than just clobbering
* the playback state. Largely limited to the legacy media APIs.
* forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that
* Media3 will throw at me will be handled in a predictable way, rather than just clobbering the
* playback state. Largely limited to the legacy media APIs.
*
* I'll add more support as I go along when I can confirm that apps will use the Media3 API and
* send more advanced commands.
* I'll add more support as I go along when I can confirm that apps will use the Media3 API and send
* more advanced commands.
*
* @author Alexander Capehart
*/
@ -229,6 +229,8 @@ class MediaSessionPlayer(
override fun removeMediaItems(fromIndex: Int, toIndex: Int) =
error("Any multi-item queue removal is unsupported")
override fun stop() = playbackManager.endSession()
// These methods I don't want MediaSession calling in any way since they'll do insane things
// that I'm not tracking. If they do call them, I will know.
@ -280,8 +282,6 @@ class MediaSessionPlayer(
override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed()
override fun stop() = notAllowed()
override fun hasNextMediaItem() = notAllowed()
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) =

View file

@ -0,0 +1,254 @@
/*
* Copyright (c) 2024 Auxio Project
* MediaSessionServiceFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.service
import android.app.Notification
import android.content.Context
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultActionFactory
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaNotification.ActionFactory
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSession.ConnectionResult
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.guava.asListenableFuture
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.service.MediaSessionBitmapLoader
import org.oxycblt.auxio.music.service.MediaItemBrowser
import org.oxycblt.auxio.playback.state.PlaybackStateManager
class MediaSessionServiceFragment
@Inject
constructor(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val actionHandler: PlaybackActionHandler,
private val mediaItemBrowser: MediaItemBrowser,
private val bitmapLoader: MediaSessionBitmapLoader,
exoHolderFactory: ExoPlaybackStateHolder.Factory
) :
MediaLibrarySession.Callback,
PlaybackActionHandler.Callback,
MediaItemBrowser.Invalidator,
PlaybackStateManager.Listener {
private val waitJob = Job()
private val waitScope = CoroutineScope(waitJob + Dispatchers.Default)
private val exoHolder = exoHolderFactory.create()
private lateinit var actionFactory: ActionFactory
private val mediaNotificationProvider =
DefaultMediaNotificationProvider.Builder(context)
.setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE)
.setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK")
.setChannelName(R.string.lbl_playback)
.build()
.also { it.setSmallIcon(R.drawable.ic_auxio_24) }
private var foregroundListener: ForegroundListener? = null
lateinit var mediaSession: MediaLibrarySession
private set
// --- MEDIASESSION CALLBACKS ---
fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession {
foregroundListener = listener
mediaSession = createSession(service)
service.addSession(mediaSession)
actionFactory = DefaultActionFactory(service)
playbackManager.addListener(this)
exoHolder.attach()
actionHandler.attach(this)
mediaItemBrowser.attach(this)
return mediaSession
}
fun handleTaskRemoved() {
if (playbackManager.progression.isPlaying) {
playbackManager.endSession()
}
}
fun release() {
waitJob.cancel()
mediaSession.release()
actionHandler.release()
exoHolder.release()
playbackManager.removeListener(this)
mediaSession.release()
foregroundListener = null
}
fun hasNotification(): Boolean = exoHolder.sessionOngoing
fun createNotification(post: (MediaNotification) -> Unit) {
val notification =
mediaNotificationProvider.createNotification(
mediaSession, mediaSession.customLayout, actionFactory) { notification ->
post(wrapMediaNotification(notification))
}
post(wrapMediaNotification(notification))
}
private fun wrapMediaNotification(notification: MediaNotification): MediaNotification {
// Pulled from MediaNotificationManager: Need to specify MediaSession token manually
// in notification
val fwkToken =
mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token
notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
return notification
}
private fun createSession(service: MediaLibraryService) =
MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this)
.setBitmapLoader(bitmapLoader)
.build()
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): ConnectionResult {
val sessionCommands =
actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS)
return ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(sessionCommands)
.setCustomLayout(actionHandler.createCustomLayout())
.build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> =
if (actionHandler.handleCommand(customCommand)) {
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
} else {
super.onCustomCommand(session, controller, customCommand, args)
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> =
Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params))
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> {
val result =
mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(result)
}
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
Futures.immediateFuture(
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val children =
mediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError<ImmutableList<MediaItem>>(
LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(children)
}
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<Void>> =
waitScope
.async {
val count = mediaItemBrowser.prepareSearch(query)
session.notifySearchResultChanged(browser, query, count, params)
LibraryResult.ofVoid()
}
.asListenableFuture()
override fun onGetSearchResult(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: MediaLibraryService.LibraryParams?
) =
waitScope
.async {
mediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
}
.asListenableFuture()
override fun onSessionEnded() {
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
override fun onCustomLayoutChanged(layout: List<CommandButton>) {
mediaSession.setCustomLayout(layout)
}
override fun invalidate(ids: List<String>) {
for (id in ids) {
mediaSession.notifyChildrenChanged(id, Int.MAX_VALUE, null)
}
}
}

View file

@ -23,14 +23,141 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.media3.session.CommandButton
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionCommands
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.AuxioService
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
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
@Inject
constructor(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val systemReceiver: SystemPlaybackReceiver
) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
interface Callback {
fun onCustomLayoutChanged(layout: List<CommandButton>)
}
private var callback: Callback? = null
fun attach(callback: Callback) {
this.callback = callback
playbackManager.addListener(this)
playbackSettings.registerListener(this)
ContextCompat.registerReceiver(
context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
}
fun release() {
callback = null
playbackManager.removeListener(this)
playbackSettings.unregisterListener(this)
context.unregisterReceiver(systemReceiver)
}
fun withCommands(commands: SessionCommands) =
commands
.buildUpon()
.add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
.add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
.add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY))
.build()
fun handleCommand(command: SessionCommand): Boolean {
when (command.customAction) {
PlaybackActions.ACTION_INC_REPEAT_MODE ->
playbackManager.repeatMode(playbackManager.repeatMode.increment())
PlaybackActions.ACTION_INVERT_SHUFFLE ->
playbackManager.shuffled(!playbackManager.isShuffled)
PlaybackActions.ACTION_EXIT -> playbackManager.endSession()
else -> return false
}
return true
}
fun createCustomLayout(): List<CommandButton> {
val actions = mutableListOf<CommandButton>()
when (playbackSettings.notificationAction) {
ActionMode.REPEAT -> {
actions.add(
CommandButton.Builder()
.setIconResId(playbackManager.repeatMode.icon)
.setDisplayName(context.getString(R.string.desc_change_repeat))
.setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle()))
.build())
}
ActionMode.SHUFFLE -> {
actions.add(
CommandButton.Builder()
.setIconResId(
if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24
else R.drawable.ic_shuffle_off_24)
.setDisplayName(context.getString(R.string.lbl_shuffle))
.setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle()))
.build())
}
else -> {}
}
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_close_24)
.setDisplayName(context.getString(R.string.desc_exit))
.setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle()))
.build())
return actions
}
override fun onPauseOnRepeatChanged() {
super.onPauseOnRepeatChanged()
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
super.onRepeatModeChanged(repeatMode)
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
super.onQueueReordered(queue, index, isShuffled)
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onNotificationActionChanged() {
super.onNotificationActionChanged()
callback?.onCustomLayoutChanged(createCustomLayout())
}
}
object PlaybackActions {
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
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.
@ -48,11 +175,11 @@ constructor(
IntentFilter().apply {
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(AudioManager.ACTION_HEADSET_PLUG)
addAction(AuxioService.ACTION_INC_REPEAT_MODE)
addAction(AuxioService.ACTION_INVERT_SHUFFLE)
addAction(AuxioService.ACTION_SKIP_PREV)
addAction(AuxioService.ACTION_PLAY_PAUSE)
addAction(AuxioService.ACTION_SKIP_NEXT)
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)
}
@ -82,26 +209,30 @@ constructor(
}
// --- AUXIO EVENTS ---
AuxioService.ACTION_PLAY_PAUSE -> {
PlaybackActions.ACTION_PLAY_PAUSE -> {
logD("Received play event")
playbackManager.playing(!playbackManager.progression.isPlaying)
}
AuxioService.ACTION_INC_REPEAT_MODE -> {
PlaybackActions.ACTION_INC_REPEAT_MODE -> {
logD("Received repeat mode event")
playbackManager.repeatMode(playbackManager.repeatMode.increment())
}
AuxioService.ACTION_INVERT_SHUFFLE -> {
PlaybackActions.ACTION_INVERT_SHUFFLE -> {
logD("Received shuffle event")
playbackManager.shuffled(!playbackManager.isShuffled)
}
AuxioService.ACTION_SKIP_PREV -> {
PlaybackActions.ACTION_SKIP_PREV -> {
logD("Received skip previous event")
playbackManager.prev()
}
AuxioService.ACTION_SKIP_NEXT -> {
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()

View file

@ -147,6 +147,9 @@ interface PlaybackStateHolder {
*/
fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?)
/** End whatever ongoing playback session may be going on */
fun endSession()
/** Reset this instance to an empty state. */
fun reset(ack: StateAck.NewPlayback)
}
@ -195,6 +198,8 @@ sealed interface StateAck {
/** @see PlaybackStateHolder.repeatMode */
data object RepeatModeChanged : StateAck
data object SessionEnded : StateAck
}
/**

View file

@ -233,8 +233,7 @@ interface PlaybackStateManager {
*/
fun seekTo(positionMs: Long)
/** Rewind to the beginning of the currently playing [Song]. */
fun rewind() = seekTo(0)
fun endSession()
/**
* Converts the current state of this instance into a [SavedState].
@ -313,6 +312,8 @@ interface PlaybackStateManager {
* @param repeatMode The new [RepeatMode].
*/
fun onRepeatModeChanged(repeatMode: RepeatMode) {}
fun onSessionEnded() {}
}
/**
@ -564,6 +565,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
stateHolder.seekTo(positionMs)
}
@Synchronized
override fun endSession() {
val stateHolder = stateHolder ?: return
logD("Ending session")
stateHolder.endSession()
}
@Synchronized
override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
@ -690,6 +698,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
)
listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) }
}
is StateAck.SessionEnded -> {
listeners.forEach { it.onSessionEnded() }
}
}
}

View file

@ -1,71 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* ForegroundServiceNotification.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui
import android.content.Context
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
/**
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* signal a Service's ongoing foreground state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
.setLightsEnabled(false)
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
*
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
/** Post this notification using [NotificationManagerCompat]. */
fun post() {
// This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground
// notification.
@Suppress("MissingPermission") notificationManager.notify(code, build())
}
/**
* Reduced representation of a [NotificationChannelCompat].
*
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
}

View file

@ -28,10 +28,10 @@ import android.os.Bundle
import android.util.SizeF
import android.view.View
import android.widget.RemoteViews
import org.oxycblt.auxio.AuxioService
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.service.PlaybackActions
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.logD
@ -339,7 +339,7 @@ class WidgetProvider : AppWidgetProvider() {
// by PlaybackService.
setOnClickPendingIntent(
R.id.widget_play_pause,
context.newBroadcastPendingIntent(AuxioService.ACTION_PLAY_PAUSE))
context.newBroadcastPendingIntent(PlaybackActions.ACTION_PLAY_PAUSE))
// Set up the play/pause button appearance. Like the Android 13 media controls, use
// a circular FAB when paused, and a squircle FAB when playing. This does require us
@ -379,9 +379,11 @@ class WidgetProvider : AppWidgetProvider() {
// Hook the skip buttons to the respective broadcasts that can be recognized
// by PlaybackService.
setOnClickPendingIntent(
R.id.widget_skip_prev, context.newBroadcastPendingIntent(AuxioService.ACTION_SKIP_PREV))
R.id.widget_skip_prev,
context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_PREV))
setOnClickPendingIntent(
R.id.widget_skip_next, context.newBroadcastPendingIntent(AuxioService.ACTION_SKIP_NEXT))
R.id.widget_skip_next,
context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_NEXT))
return this
}
@ -403,10 +405,10 @@ class WidgetProvider : AppWidgetProvider() {
// be recognized by PlaybackService.
setOnClickPendingIntent(
R.id.widget_repeat,
context.newBroadcastPendingIntent(AuxioService.ACTION_INC_REPEAT_MODE))
context.newBroadcastPendingIntent(PlaybackActions.ACTION_INC_REPEAT_MODE))
setOnClickPendingIntent(
R.id.widget_shuffle,
context.newBroadcastPendingIntent(AuxioService.ACTION_INVERT_SHUFFLE))
context.newBroadcastPendingIntent(PlaybackActions.ACTION_INVERT_SHUFFLE))
// Set up the repeat/shuffle buttons. When working with RemoteViews, we will
// need to hard-code different accent tinting configurations, as stateful drawables