service: break into components
This commit is contained in:
parent
02877972af
commit
be23208f72
15 changed files with 826 additions and 655 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 ->
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) =
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue