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
|
package org.oxycblt.auxio
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Notification
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
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.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
|
||||||
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
|
|
||||||
import androidx.media3.session.MediaNotification
|
|
||||||
import androidx.media3.session.MediaSession
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import org.oxycblt.auxio.music.service.IndexingServiceFragment
|
||||||
import kotlinx.coroutines.Dispatchers
|
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
|
||||||
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
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AuxioService :
|
class AuxioService : MediaLibraryService(), ForegroundListener {
|
||||||
MediaLibraryService(),
|
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
|
||||||
MediaLibrarySession.Callback,
|
|
||||||
MusicRepository.IndexingWorker,
|
|
||||||
MusicRepository.IndexingListener,
|
|
||||||
MusicRepository.UpdateListener,
|
|
||||||
MusicSettings.Listener,
|
|
||||||
PlaybackStateManager.Listener,
|
|
||||||
PlaybackSettings.Listener {
|
|
||||||
private val serviceJob = Job()
|
|
||||||
private var inPlayback = false
|
|
||||||
|
|
||||||
@Inject lateinit var musicRepository: MusicRepository
|
@Inject lateinit var indexingFragment: IndexingServiceFragment
|
||||||
@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
|
|
||||||
|
|
||||||
@SuppressLint("WrongConstant")
|
@SuppressLint("WrongConstant")
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
mediaSessionFragment.attach(this, this)
|
||||||
indexingNotification = IndexingNotification(this)
|
indexingFragment.attach(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
super.onTaskRemoved(rootIntent)
|
super.onTaskRemoved(rootIntent)
|
||||||
if (!playbackManager.progression.isPlaying) {
|
mediaSessionFragment.handleTaskRemoved()
|
||||||
// Stop the service if not playing, continue playing in the background
|
|
||||||
// otherwise.
|
|
||||||
endSession()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
// De-initialize core service components first.
|
indexingFragment.release()
|
||||||
serviceJob.cancel()
|
mediaSessionFragment.release()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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 =
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
||||||
mediaSession
|
mediaSessionFragment.mediaSession
|
||||||
|
|
||||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||||
logD("Notification update requested")
|
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
||||||
updateForeground(forMusic = false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun postMediaNotification(notification: MediaNotification, session: MediaSession) {
|
override fun updateForeground(change: ForegroundListener.Change) {
|
||||||
// Pulled from MediaNotificationManager: Need to specify MediaSession token manually
|
if (mediaSessionFragment.hasNotification()) {
|
||||||
// in notification
|
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
||||||
val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token
|
mediaSessionFragment.createNotification {
|
||||||
notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
|
startForeground(it.notificationId, it.notification)
|
||||||
startForeground(notification.notificationId, notification.notification)
|
}
|
||||||
}
|
}
|
||||||
|
// Nothing changed, but don't show anything music related since we can always
|
||||||
// --- MEDIASESSION CALLBACKS ---
|
// index during playback.
|
||||||
|
} else {
|
||||||
override fun onConnect(
|
indexingFragment.createNotification {
|
||||||
session: MediaSession,
|
if (it != null) {
|
||||||
controller: MediaSession.ControllerInfo
|
startForeground(it.code, it.build())
|
||||||
): ConnectionResult {
|
} else {
|
||||||
val sessionCommands =
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
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 onRepeatModeChanged(repeatMode: RepeatMode) {
|
|
||||||
super.onRepeatModeChanged(repeatMode)
|
interface ForegroundListener {
|
||||||
updateCustomButtons()
|
fun updateForeground(change: Change)
|
||||||
}
|
|
||||||
|
enum class Change {
|
||||||
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
|
MEDIA_SESSION,
|
||||||
super.onQueueReordered(queue, index, isShuffled)
|
INDEXER
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ import org.oxycblt.auxio.image.BitmapProvider
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
import org.oxycblt.auxio.music.service.MediaSessionUID
|
import org.oxycblt.auxio.music.service.MediaSessionUID
|
||||||
|
|
||||||
class NeoBitmapLoader
|
class MediaSessionBitmapLoader
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val musicRepository: MusicRepository,
|
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.content.Context
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.IndexingProgress
|
import org.oxycblt.auxio.music.IndexingProgress
|
||||||
import org.oxycblt.auxio.ui.ForegroundServiceNotification
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
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.
|
* @param context [Context] required to create the notification.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class IndexingNotification(private val context: Context) :
|
class IndexingNotification(private val context: Context) :
|
||||||
ForegroundServiceNotification(context, indexerChannel) {
|
IndexerNotification(context, indexerChannel) {
|
||||||
private var lastUpdateTime = -1L
|
private var lastUpdateTime = -1L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -92,13 +133,12 @@ class IndexingNotification(private val context: Context) :
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
|
* A static [IndexerNotification] that signals to the user that the app is currently monitoring the
|
||||||
* monitoring the music library for changes.
|
* music library for changes.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ObservingNotification(context: Context) :
|
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
|
||||||
ForegroundServiceNotification(context, indexerChannel) {
|
|
||||||
init {
|
init {
|
||||||
setSmallIcon(R.drawable.ic_indexer_24)
|
setSmallIcon(R.drawable.ic_indexer_24)
|
||||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
@ -116,5 +156,5 @@ class ObservingNotification(context: Context) :
|
||||||
|
|
||||||
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
|
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
|
||||||
private val indexerChannel =
|
private val indexerChannel =
|
||||||
ForegroundServiceNotification.ChannelInfo(
|
IndexerNotification.ChannelInfo(
|
||||||
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
|
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2024 Auxio Project
|
* 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
|
* 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
|
* 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.music.user.UserLibrary
|
||||||
import org.oxycblt.auxio.search.SearchEngine
|
import org.oxycblt.auxio.search.SearchEngine
|
||||||
|
|
||||||
class MusicMediaItemBrowser
|
class MediaItemBrowser
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
@ -49,19 +49,42 @@ constructor(
|
||||||
private val browserJob = Job()
|
private val browserJob = Job()
|
||||||
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
|
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
|
||||||
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
|
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)
|
musicRepository.addUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
browserJob.cancel()
|
browserJob.cancel()
|
||||||
|
invalidator = null
|
||||||
musicRepository.removeUpdateListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
val deviceLibrary = musicRepository.deviceLibrary
|
||||||
|
var invalidateSearch = false
|
||||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
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) {
|
for (entry in searchResults.entries) {
|
||||||
entry.value.cancel()
|
entry.value.cancel()
|
||||||
}
|
}
|
||||||
|
@ -113,13 +136,7 @@ constructor(
|
||||||
is MediaSessionUID.Category -> {
|
is MediaSessionUID.Category -> {
|
||||||
when (mediaSessionUID) {
|
when (mediaSessionUID) {
|
||||||
MediaSessionUID.Category.ROOT ->
|
MediaSessionUID.Category.ROOT ->
|
||||||
listOf(
|
MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) }
|
||||||
MediaSessionUID.Category.SONGS,
|
|
||||||
MediaSessionUID.Category.ALBUMS,
|
|
||||||
MediaSessionUID.Category.ARTISTS,
|
|
||||||
MediaSessionUID.Category.GENRES,
|
|
||||||
MediaSessionUID.Category.PLAYLISTS)
|
|
||||||
.map { it.toMediaItem(context) }
|
|
||||||
MediaSessionUID.Category.SONGS ->
|
MediaSessionUID.Category.SONGS ->
|
||||||
deviceLibrary.songs.map { it.toMediaItem(context, null) }
|
deviceLibrary.songs.map { it.toMediaItem(context, null) }
|
||||||
MediaSessionUID.Category.ALBUMS ->
|
MediaSessionUID.Category.ALBUMS ->
|
|
@ -215,6 +215,10 @@ sealed interface MediaSessionUID {
|
||||||
PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
||||||
|
|
||||||
override fun toString() = "$ID_CATEGORY:$id"
|
override fun toString() = "$ID_CATEGORY:$id"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Single(val uid: Music.UID) : MediaSessionUID {
|
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 currentSaveJob: Job? = null
|
||||||
private var openAudioEffectSession = false
|
private var openAudioEffectSession = false
|
||||||
|
|
||||||
|
var sessionOngoing = false
|
||||||
|
private set
|
||||||
|
|
||||||
fun attach() {
|
fun attach() {
|
||||||
player.addListener(this)
|
player.addListener(this)
|
||||||
playbackManager.registerStateHolder(this)
|
playbackManager.registerStateHolder(this)
|
||||||
|
@ -358,6 +361,20 @@ class ExoPlaybackStateHolder(
|
||||||
ack?.let { playbackManager.ack(this, it) }
|
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) {
|
override fun reset(ack: StateAck.NewPlayback) {
|
||||||
player.setMediaItems(listOf())
|
player.setMediaItems(listOf())
|
||||||
playbackManager.ack(this, ack)
|
playbackManager.ack(this, ack)
|
||||||
|
@ -372,6 +389,7 @@ class ExoPlaybackStateHolder(
|
||||||
if (player.playWhenReady) {
|
if (player.playWhenReady) {
|
||||||
// Mark that we have started playing so that the notification can now be posted.
|
// Mark that we have started playing so that the notification can now be posted.
|
||||||
logD("Player has started playing")
|
logD("Player has started playing")
|
||||||
|
sessionOngoing = true
|
||||||
if (!openAudioEffectSession) {
|
if (!openAudioEffectSession) {
|
||||||
// Convention to start an audioeffect session on play/pause rather than
|
// Convention to start an audioeffect session on play/pause rather than
|
||||||
// start/stop
|
// 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
|
* 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
|
* forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that
|
||||||
* that Media3 will throw at me will be handled in a predictable way, rather than just clobbering
|
* Media3 will throw at me will be handled in a predictable way, rather than just clobbering the
|
||||||
* the playback state. Largely limited to the legacy media APIs.
|
* 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
|
* I'll add more support as I go along when I can confirm that apps will use the Media3 API and send
|
||||||
* send more advanced commands.
|
* more advanced commands.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart
|
* @author Alexander Capehart
|
||||||
*/
|
*/
|
||||||
|
@ -229,6 +229,8 @@ class MediaSessionPlayer(
|
||||||
override fun removeMediaItems(fromIndex: Int, toIndex: Int) =
|
override fun removeMediaItems(fromIndex: Int, toIndex: Int) =
|
||||||
error("Any multi-item queue removal is unsupported")
|
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
|
// 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.
|
// 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 setPlayWhenReady(playWhenReady: Boolean) = notAllowed()
|
||||||
|
|
||||||
override fun stop() = notAllowed()
|
|
||||||
|
|
||||||
override fun hasNextMediaItem() = notAllowed()
|
override fun hasNextMediaItem() = notAllowed()
|
||||||
|
|
||||||
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) =
|
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.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.media.AudioManager
|
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 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.PlaybackSettings
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
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
|
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
|
||||||
* active [IntentFilter] to be registered.
|
* active [IntentFilter] to be registered.
|
||||||
|
@ -48,11 +175,11 @@ constructor(
|
||||||
IntentFilter().apply {
|
IntentFilter().apply {
|
||||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||||
addAction(AuxioService.ACTION_INC_REPEAT_MODE)
|
addAction(PlaybackActions.ACTION_INC_REPEAT_MODE)
|
||||||
addAction(AuxioService.ACTION_INVERT_SHUFFLE)
|
addAction(PlaybackActions.ACTION_INVERT_SHUFFLE)
|
||||||
addAction(AuxioService.ACTION_SKIP_PREV)
|
addAction(PlaybackActions.ACTION_SKIP_PREV)
|
||||||
addAction(AuxioService.ACTION_PLAY_PAUSE)
|
addAction(PlaybackActions.ACTION_PLAY_PAUSE)
|
||||||
addAction(AuxioService.ACTION_SKIP_NEXT)
|
addAction(PlaybackActions.ACTION_SKIP_NEXT)
|
||||||
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,26 +209,30 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AUXIO EVENTS ---
|
// --- AUXIO EVENTS ---
|
||||||
AuxioService.ACTION_PLAY_PAUSE -> {
|
PlaybackActions.ACTION_PLAY_PAUSE -> {
|
||||||
logD("Received play event")
|
logD("Received play event")
|
||||||
playbackManager.playing(!playbackManager.progression.isPlaying)
|
playbackManager.playing(!playbackManager.progression.isPlaying)
|
||||||
}
|
}
|
||||||
AuxioService.ACTION_INC_REPEAT_MODE -> {
|
PlaybackActions.ACTION_INC_REPEAT_MODE -> {
|
||||||
logD("Received repeat mode event")
|
logD("Received repeat mode event")
|
||||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||||
}
|
}
|
||||||
AuxioService.ACTION_INVERT_SHUFFLE -> {
|
PlaybackActions.ACTION_INVERT_SHUFFLE -> {
|
||||||
logD("Received shuffle event")
|
logD("Received shuffle event")
|
||||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||||
}
|
}
|
||||||
AuxioService.ACTION_SKIP_PREV -> {
|
PlaybackActions.ACTION_SKIP_PREV -> {
|
||||||
logD("Received skip previous event")
|
logD("Received skip previous event")
|
||||||
playbackManager.prev()
|
playbackManager.prev()
|
||||||
}
|
}
|
||||||
AuxioService.ACTION_SKIP_NEXT -> {
|
PlaybackActions.ACTION_SKIP_NEXT -> {
|
||||||
logD("Received skip next event")
|
logD("Received skip next event")
|
||||||
playbackManager.next()
|
playbackManager.next()
|
||||||
}
|
}
|
||||||
|
PlaybackActions.ACTION_EXIT -> {
|
||||||
|
logD("Received exit event")
|
||||||
|
playbackManager.endSession()
|
||||||
|
}
|
||||||
WidgetProvider.ACTION_WIDGET_UPDATE -> {
|
WidgetProvider.ACTION_WIDGET_UPDATE -> {
|
||||||
logD("Received widget update event")
|
logD("Received widget update event")
|
||||||
widgetComponent.update()
|
widgetComponent.update()
|
||||||
|
|
|
@ -147,6 +147,9 @@ interface PlaybackStateHolder {
|
||||||
*/
|
*/
|
||||||
fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?)
|
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. */
|
/** Reset this instance to an empty state. */
|
||||||
fun reset(ack: StateAck.NewPlayback)
|
fun reset(ack: StateAck.NewPlayback)
|
||||||
}
|
}
|
||||||
|
@ -195,6 +198,8 @@ sealed interface StateAck {
|
||||||
|
|
||||||
/** @see PlaybackStateHolder.repeatMode */
|
/** @see PlaybackStateHolder.repeatMode */
|
||||||
data object RepeatModeChanged : StateAck
|
data object RepeatModeChanged : StateAck
|
||||||
|
|
||||||
|
data object SessionEnded : StateAck
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -233,8 +233,7 @@ interface PlaybackStateManager {
|
||||||
*/
|
*/
|
||||||
fun seekTo(positionMs: Long)
|
fun seekTo(positionMs: Long)
|
||||||
|
|
||||||
/** Rewind to the beginning of the currently playing [Song]. */
|
fun endSession()
|
||||||
fun rewind() = seekTo(0)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the current state of this instance into a [SavedState].
|
* Converts the current state of this instance into a [SavedState].
|
||||||
|
@ -313,6 +312,8 @@ interface PlaybackStateManager {
|
||||||
* @param repeatMode The new [RepeatMode].
|
* @param repeatMode The new [RepeatMode].
|
||||||
*/
|
*/
|
||||||
fun onRepeatModeChanged(repeatMode: RepeatMode) {}
|
fun onRepeatModeChanged(repeatMode: RepeatMode) {}
|
||||||
|
|
||||||
|
fun onSessionEnded() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -564,6 +565,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
||||||
stateHolder.seekTo(positionMs)
|
stateHolder.seekTo(positionMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun endSession() {
|
||||||
|
val stateHolder = stateHolder ?: return
|
||||||
|
logD("Ending session")
|
||||||
|
stateHolder.endSession()
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
|
override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
|
||||||
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
|
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
|
||||||
|
@ -690,6 +698,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
||||||
)
|
)
|
||||||
listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) }
|
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.util.SizeF
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import org.oxycblt.auxio.AuxioService
|
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
|
import org.oxycblt.auxio.playback.service.PlaybackActions
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -339,7 +339,7 @@ class WidgetProvider : AppWidgetProvider() {
|
||||||
// by PlaybackService.
|
// by PlaybackService.
|
||||||
setOnClickPendingIntent(
|
setOnClickPendingIntent(
|
||||||
R.id.widget_play_pause,
|
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
|
// 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
|
// 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
|
// Hook the skip buttons to the respective broadcasts that can be recognized
|
||||||
// by PlaybackService.
|
// by PlaybackService.
|
||||||
setOnClickPendingIntent(
|
setOnClickPendingIntent(
|
||||||
R.id.widget_skip_prev, context.newBroadcastPendingIntent(AuxioService.ACTION_SKIP_PREV))
|
R.id.widget_skip_prev,
|
||||||
|
context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_PREV))
|
||||||
setOnClickPendingIntent(
|
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
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -403,10 +405,10 @@ class WidgetProvider : AppWidgetProvider() {
|
||||||
// be recognized by PlaybackService.
|
// be recognized by PlaybackService.
|
||||||
setOnClickPendingIntent(
|
setOnClickPendingIntent(
|
||||||
R.id.widget_repeat,
|
R.id.widget_repeat,
|
||||||
context.newBroadcastPendingIntent(AuxioService.ACTION_INC_REPEAT_MODE))
|
context.newBroadcastPendingIntent(PlaybackActions.ACTION_INC_REPEAT_MODE))
|
||||||
setOnClickPendingIntent(
|
setOnClickPendingIntent(
|
||||||
R.id.widget_shuffle,
|
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
|
// Set up the repeat/shuffle buttons. When working with RemoteViews, we will
|
||||||
// need to hard-code different accent tinting configurations, as stateful drawables
|
// need to hard-code different accent tinting configurations, as stateful drawables
|
||||||
|
|
Loading…
Reference in a new issue