service: break up

Break up the monster AuxioService into sub-classes, keeping just the
major lifecycle and music stuff in AuxioService for now (which will
likely be split out itself eventually)
This commit is contained in:
Alexander Capehart 2024-04-10 19:18:04 -06:00
parent 5b8518a567
commit 99a527983b
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 2238 additions and 1925 deletions

View file

@ -82,7 +82,7 @@
Service handling music playback, system components, and state saving.
-->
<service
android:name=".service.AuxioService"
android:name=".AuxioService"
android:foregroundServiceType="mediaPlayback"
android:icon="@mipmap/ic_launcher"
android:exported="true"

View file

@ -0,0 +1,577 @@
/*
* Copyright (c) 2024 Auxio Project
* AuxioService.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
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.RepeatMode
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
// TODO: Android Auto Hookup
// TODO: Custom notif
@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
@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
@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)
}
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()
}
}
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()
}
// --- 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 (playbackManager.progression.isPlaying) {
inPlayback = true
}
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_DETACH)
}
// 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
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
logD("Notification update requested")
updateForeground(forMusic = false)
}
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 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(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 {
musicMediaItemBrowser.prepareSearch(query)
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 onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
super.onQueueReordered(queue, index, isShuffled)
updateCustomButtons()
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
super.onRepeatModeChanged(repeatMode)
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"
}
}

View file

@ -31,7 +31,6 @@ import javax.inject.Inject
import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.service.AuxioService
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2024 Auxio Project
* CoilBitmapLoader.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.image.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.BitmapLoader
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import javax.inject.Inject
import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.service.MediaSessionUID
class NeoBitmapLoader
@Inject
constructor(
private val musicRepository: MusicRepository,
private val bitmapProvider: BitmapProvider
) : BitmapLoader {
override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
throw NotImplementedError()
}
override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
throw NotImplementedError()
}
override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture<Bitmap> {
throw NotImplementedError()
}
override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap>? {
val deviceLibrary = musicRepository.deviceLibrary ?: return null
val future = SettableFuture.create<Bitmap>()
val song =
when (val uid =
metadata.extras?.getString("uid")?.let { MediaSessionUID.fromString(it) }) {
is MediaSessionUID.Single -> deviceLibrary.findSong(uid.uid)
is MediaSessionUID.Joined -> deviceLibrary.findSong(uid.childUid)
else -> return null
}
?: return null
bitmapProvider.load(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
if (bitmap == null) {
future.setException(IllegalStateException("Bitmap is null"))
} else {
future.set(bitmap)
}
}
})
return future
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.service
package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.SystemClock
@ -25,6 +25,7 @@ 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

View file

@ -0,0 +1,272 @@
/*
* Copyright (c) 2024 Auxio Project
* MediaItemTranslation.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.Bundle
import androidx.annotation.StringRes
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.getPlural
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
val metadata =
MediaMetadata.Builder()
.setTitle(context.getString(nameRes))
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(mediaType)
.build()
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build()
}
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
val mediaSessionUID =
if (parent == null) {
MediaSessionUID.Single(uid)
} else {
MediaSessionUID.Joined(parent.uid, uid)
}
val metadata =
MediaMetadata.Builder()
.setTitle(name.resolve(context))
.setArtist(artists.resolveNames(context))
.setAlbumTitle(album.name.resolve(context))
.setAlbumArtist(album.artists.resolveNames(context))
.setTrackNumber(track)
.setDiscNumber(disc?.number)
.setGenre(genres.resolveNames(context))
.setDisplayTitle(name.resolve(context))
.setSubtitle(artists.resolveNames(context))
.setRecordingYear(album.dates?.min?.year)
.setRecordingMonth(album.dates?.min?.month)
.setRecordingDay(album.dates?.min?.day)
.setReleaseYear(album.dates?.min?.year)
.setReleaseMonth(album.dates?.min?.month)
.setReleaseDay(album.dates?.min?.day)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.setIsPlayable(true)
.setIsBrowsable(false)
.setArtworkUri(album.coverUri.mediaStore)
.setExtras(
Bundle().apply {
putString("uid", mediaSessionUID.toString())
putLong("durationMs", durationMs)
})
.build()
return MediaItem.Builder()
.setUri(uri)
.setMediaId(mediaSessionUID.toString())
.setMediaMetadata(metadata)
.build()
}
fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem {
val mediaSessionUID =
if (parent == null) {
MediaSessionUID.Single(uid)
} else {
MediaSessionUID.Joined(parent.uid, uid)
}
val metadata =
MediaMetadata.Builder()
.setTitle(name.resolve(context))
.setArtist(artists.resolveNames(context))
.setAlbumTitle(name.resolve(context))
.setAlbumArtist(artists.resolveNames(context))
.setRecordingYear(dates?.min?.year)
.setRecordingMonth(dates?.min?.month)
.setRecordingDay(dates?.min?.day)
.setReleaseYear(dates?.min?.year)
.setReleaseMonth(dates?.min?.month)
.setReleaseDay(dates?.min?.day)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(coverUri.mediaStore)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
.setMediaId(mediaSessionUID.toString())
.setMediaMetadata(metadata)
.build()
}
fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem {
val mediaSessionUID =
if (parent == null) {
MediaSessionUID.Single(uid)
} else {
MediaSessionUID.Joined(parent.uid, uid)
}
val metadata =
MediaMetadata.Builder()
.setTitle(name.resolve(context))
.setSubtitle(
context.getString(
R.string.fmt_two,
if (explicitAlbums.isNotEmpty()) {
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
} else {
context.getString(R.string.def_album_count)
},
if (songs.isNotEmpty()) {
context.getPlural(R.plurals.fmt_song_count, songs.size)
} else {
context.getString(R.string.def_song_count)
}))
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
.setIsPlayable(true)
.setIsBrowsable(true)
.setGenre(genres.resolveNames(context))
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
.setMediaId(mediaSessionUID.toString())
.setMediaMetadata(metadata)
.build()
}
fun Genre.toMediaItem(context: Context): MediaItem {
val mediaSessionUID = MediaSessionUID.Single(uid)
val metadata =
MediaMetadata.Builder()
.setTitle(name.resolve(context))
.setSubtitle(
if (songs.isNotEmpty()) {
context.getPlural(R.plurals.fmt_song_count, songs.size)
} else {
context.getString(R.string.def_song_count)
})
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
.setMediaId(mediaSessionUID.toString())
.setMediaMetadata(metadata)
.build()
}
fun Playlist.toMediaItem(context: Context): MediaItem {
val mediaSessionUID = MediaSessionUID.Single(uid)
val metadata =
MediaMetadata.Builder()
.setTitle(name.resolve(context))
.setSubtitle(
if (songs.isNotEmpty()) {
context.getPlural(R.plurals.fmt_song_count, songs.size)
} else {
context.getString(R.string.def_song_count)
})
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
.setMediaId(mediaSessionUID.toString())
.setMediaMetadata(metadata)
.build()
}
fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
val uid = MediaSessionUID.fromString(mediaId) ?: return null
return when (uid) {
is MediaSessionUID.Single -> {
deviceLibrary.findSong(uid.uid)
}
is MediaSessionUID.Joined -> {
deviceLibrary.findSong(uid.childUid)
}
is MediaSessionUID.Category -> null
}
}
sealed interface MediaSessionUID {
enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) :
MediaSessionUID {
ROOT("root", R.string.info_app_name, null),
SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC),
ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
override fun toString() = "$ID_CATEGORY:$id"
}
data class Single(val uid: Music.UID) : MediaSessionUID {
override fun toString() = "$ID_ITEM:$uid"
}
data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID {
override fun toString() = "$ID_ITEM:$parentUid>$childUid"
}
companion object {
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
fun fromString(str: String): MediaSessionUID? {
val parts = str.split(":", limit = 2)
if (parts.size != 2) {
return null
}
return when (parts[0]) {
ID_CATEGORY ->
when (parts[1]) {
Category.ROOT.id -> Category.ROOT
Category.SONGS.id -> Category.SONGS
Category.ALBUMS.id -> Category.ALBUMS
Category.ARTISTS.id -> Category.ARTISTS
Category.GENRES.id -> Category.GENRES
Category.PLAYLISTS.id -> Category.PLAYLISTS
else -> null
}
ID_ITEM -> {
val uids = parts[1].split(">", limit = 2)
if (uids.size == 1) {
Music.UID.fromString(uids[0])?.let { Single(it) }
} else {
Music.UID.fromString(uids[0])?.let { parent ->
Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
}
}
}
else -> return null
}
}
}
}

View file

@ -0,0 +1,245 @@
/*
* Copyright (c) 2024 Auxio Project
* MusicMediaItemBrowser.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 androidx.media3.common.MediaItem
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.search.SearchEngine
class MusicMediaItemBrowser
@Inject
constructor(
@ApplicationContext private val context: Context,
private val musicRepository: MusicRepository,
private val searchEngine: SearchEngine
) : MusicRepository.UpdateListener {
private val browserJob = Job()
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
fun attach() {
musicRepository.addUpdateListener(this)
}
fun release() {
musicRepository.removeUpdateListener(this)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (changes.deviceLibrary) {
for (entry in searchResults.entries) {
entry.value.cancel()
}
searchResults.clear()
}
}
val root: MediaItem
get() = MediaSessionUID.Category.ROOT.toMediaItem(context)
fun getItem(mediaId: String): MediaItem? {
val music =
when (val uid = MediaSessionUID.fromString(mediaId)) {
is MediaSessionUID.Category -> return uid.toMediaItem(context)
is MediaSessionUID.Single ->
musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) }
is MediaSessionUID.Joined ->
musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) }
null -> null
}
?: return null
return when (music) {
is Album -> music.toMediaItem(context, null)
is Artist -> music.toMediaItem(context, null)
is Genre -> music.toMediaItem(context)
is Playlist -> music.toMediaItem(context)
is Song -> music.toMediaItem(context, null)
}
}
fun getChildren(parentId: String, page: Int, pageSize: Int): List<MediaItem>? {
val deviceLibrary = musicRepository.deviceLibrary
val userLibrary = musicRepository.userLibrary
if (deviceLibrary == null || userLibrary == null) {
return listOf()
}
val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null
return items.paginate(page, pageSize)
}
private fun getMediaItemList(
id: String,
deviceLibrary: DeviceLibrary,
userLibrary: UserLibrary
): List<MediaItem>? {
return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
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.SONGS ->
deviceLibrary.songs.map { it.toMediaItem(context, null) }
MediaSessionUID.Category.ALBUMS ->
deviceLibrary.albums.map { it.toMediaItem(context, null) }
MediaSessionUID.Category.ARTISTS ->
deviceLibrary.artists.map { it.toMediaItem(context, null) }
MediaSessionUID.Category.GENRES ->
deviceLibrary.genres.map { it.toMediaItem(context) }
MediaSessionUID.Category.PLAYLISTS ->
userLibrary.playlists.map { it.toMediaItem(context) }
}
}
is MediaSessionUID.Single -> {
getChildMediaItems(mediaSessionUID.uid) ?: return null
}
is MediaSessionUID.Joined -> {
getChildMediaItems(mediaSessionUID.childUid) ?: return null
}
null -> return null
}
}
private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? {
return when (val item = musicRepository.find(uid)) {
is Album -> {
item.songs.map { it.toMediaItem(context, item) }
}
is Artist -> {
(item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(context, item) } +
item.songs.map { it.toMediaItem(context, item) }
}
is Genre -> {
item.songs.map { it.toMediaItem(context, item) }
}
is Playlist -> {
item.songs.map { it.toMediaItem(context, item) }
}
is Song,
null -> return null
}
}
suspend fun prepareSearch(query: String) {
val deviceLibrary = musicRepository.deviceLibrary
val userLibrary = musicRepository.userLibrary
if (deviceLibrary == null || userLibrary == null) {
return
}
if (query.isEmpty()) {
return
}
searchTo(query, deviceLibrary, userLibrary).await()
}
suspend fun getSearchResult(
query: String,
page: Int,
pageSize: Int,
): List<MediaItem>? {
val deviceLibrary = musicRepository.deviceLibrary
val userLibrary = musicRepository.userLibrary
if (deviceLibrary == null || userLibrary == null) {
return listOf()
}
if (query.isEmpty()) {
return listOf()
}
val existing = searchResults[query]
if (existing != null) {
return existing.await().concat().paginate(page, pageSize)
}
return searchTo(query, deviceLibrary, userLibrary).await().concat().paginate(page, pageSize)
}
private fun SearchEngine.Items.concat(): MutableList<MediaItem> {
val music = mutableListOf<MediaItem>()
if (songs != null) {
music.addAll(songs.map { it.toMediaItem(context, null) })
}
if (albums != null) {
music.addAll(albums.map { it.toMediaItem(context, null) })
}
if (artists != null) {
music.addAll(artists.map { it.toMediaItem(context, null) })
}
if (genres != null) {
music.addAll(genres.map { it.toMediaItem(context) })
}
if (playlists != null) {
music.addAll(playlists.map { it.toMediaItem(context) })
}
return music
}
private fun searchTo(query: String, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) =
searchScope.async {
val items =
SearchEngine.Items(
deviceLibrary.songs,
deviceLibrary.albums,
deviceLibrary.artists,
deviceLibrary.genres,
userLibrary.playlists)
searchEngine.search(items, query)
}
private fun List<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? {
if (page == Int.MAX_VALUE) {
// I think if someone requests this page it more or less implies that I should
// return all of the pages.
return this
}
val start = page * pageSize
val end = (page + 1) * pageSize
if (pageSize == 0 || start !in indices || end - 1 !in indices) {
// These pages are probably invalid. Hopefully this won't backfire.
return null
}
return subList(page * pageSize, (page + 1) * pageSize).toMutableList()
}
}

View file

@ -0,0 +1,551 @@
/*
* Copyright (c) 2024 Auxio Project
* ExoPlaybackStateHolder.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.content.Context
import android.content.Intent
import android.media.audiofx.AudioEffect
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.MediaSource
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.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.service.toMediaItem
import org.oxycblt.auxio.music.service.toSong
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.persist.PersistenceRepository
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackCommand
import org.oxycblt.auxio.playback.state.PlaybackStateHolder
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.RawQueue
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.state.ShuffleMode
import org.oxycblt.auxio.playback.state.StateAck
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
class ExoPlaybackStateHolder(
private val context: Context,
private val player: ExoPlayer,
private val playbackManager: PlaybackStateManager,
private val persistenceRepository: PersistenceRepository,
private val playbackSettings: PlaybackSettings,
private val commandFactory: PlaybackCommand.Factory,
private val musicRepository: MusicRepository
) :
PlaybackStateHolder,
Player.Listener,
MusicRepository.UpdateListener,
PlaybackSettings.Listener {
private val saveJob = Job()
private val saveScope = CoroutineScope(Dispatchers.IO + saveJob)
private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob)
private var currentSaveJob: Job? = null
private var openAudioEffectSession = false
fun attach() {
player.addListener(this)
playbackManager.registerStateHolder(this)
playbackSettings.registerListener(this)
musicRepository.addUpdateListener(this)
}
fun release() {
saveJob.cancel()
player.removeListener(this)
playbackManager.unregisterStateHolder(this)
musicRepository.removeUpdateListener(this)
player.release()
}
override var parent: MusicParent? = null
private set
val mediaSessionPlayer: Player
get() = MediaSessionPlayer(player, playbackManager, commandFactory, musicRepository)
override val progression: Progression
get() {
val mediaItem = player.currentMediaItem ?: return Progression.nil()
val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE
val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration)
return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition)
}
override val repeatMode
get() =
when (val repeatMode = player.repeatMode) {
Player.REPEAT_MODE_OFF -> RepeatMode.NONE
Player.REPEAT_MODE_ONE -> RepeatMode.TRACK
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
else -> throw IllegalStateException("Unknown repeat mode: $repeatMode")
}
override val audioSessionId: Int
get() = player.audioSessionId
override fun resolveQueue(): RawQueue {
val deviceLibrary =
musicRepository.deviceLibrary
// No library, cannot do anything.
?: return RawQueue(emptyList(), emptyList(), 0)
val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) }
val shuffledMapping =
if (player.shuffleModeEnabled) {
player.unscrambleQueueIndices()
} else {
emptyList()
}
return RawQueue(
heap.mapNotNull { it.toSong(deviceLibrary) },
shuffledMapping,
player.currentMediaItemIndex)
}
override fun handleDeferred(action: DeferredPlayback): Boolean {
val deviceLibrary =
musicRepository.deviceLibrary
// No library, cannot do anything.
?: return false
when (action) {
// Restore state -> Start a new restoreState job
is DeferredPlayback.RestoreState -> {
logD("Restoring playback state")
restoreScope.launch {
persistenceRepository.readState()?.let {
// Apply the saved state on the main thread to prevent code expecting
// state updates on the main thread from crashing.
withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) }
}
}
}
// Shuffle all -> Start new playback from all songs
is DeferredPlayback.ShuffleAll -> {
logD("Shuffling all tracks")
playbackManager.play(
requireNotNull(commandFactory.all(ShuffleMode.ON)) {
"Invalid playback parameters"
})
}
// Open -> Try to find the Song for the given file and then play it from all songs
is DeferredPlayback.Open -> {
logD("Opening specified file")
deviceLibrary.findSongForUri(context, action.uri)?.let { song ->
playbackManager.play(
requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) {
"Invalid playback parameters"
})
}
}
}
return true
}
override fun playing(playing: Boolean) {
player.playWhenReady = playing
}
override fun seekTo(positionMs: Long) {
player.seekTo(positionMs)
// Ack/state save handled on discontinuity
}
override fun repeatMode(repeatMode: RepeatMode) {
player.repeatMode =
when (repeatMode) {
RepeatMode.NONE -> Player.REPEAT_MODE_OFF
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
}
updatePauseOnRepeat()
playbackManager.ack(this, StateAck.RepeatModeChanged)
deferSave()
}
override fun newPlayback(command: PlaybackCommand) {
parent = command.parent
player.shuffleModeEnabled = command.shuffled
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
val startIndex =
command.song
?.let { command.queue.indexOf(it) }
.also { check(it != -1) { "Start song not in queue" } }
if (command.shuffled) {
player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1))
}
val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled)
player.seekTo(target, C.TIME_UNSET)
player.prepare()
player.play()
playbackManager.ack(this, StateAck.NewPlayback)
deferSave()
}
override fun shuffled(shuffled: Boolean) {
player.setShuffleModeEnabled(shuffled)
if (player.shuffleModeEnabled) {
// Have to manually refresh the shuffle seed and anchor it to the new current songs
player.setShuffleOrder(
BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex))
}
playbackManager.ack(this, StateAck.QueueReordered)
deferSave()
}
override fun next() {
// Replicate the old pseudo-circular queue behavior when no repeat option is implemented.
// Basically, you can't skip back and wrap around the queue, but you can skip forward and
// wrap around the queue, albeit playback will be paused.
if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) {
player.seekToNext()
if (!playbackSettings.rememberPause) {
player.play()
}
} else {
player.seekTo(
player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET)
// TODO: Dislike the UX implications of this, I feel should I bite the bullet
// and switch to dynamic skip enable/disable?
if (!playbackSettings.rememberPause) {
player.pause()
}
}
// Ack/state save is handled in timeline change
}
override fun prev() {
if (playbackSettings.rewindWithPrev) {
player.seekToPrevious()
} else {
player.seekToPreviousMediaItem()
}
if (!playbackSettings.rememberPause) {
player.play()
}
// Ack/state save is handled in timeline change
}
override fun goto(index: Int) {
val indices = player.unscrambleQueueIndices()
if (indices.isEmpty()) {
return
}
val trueIndex = indices[index]
player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic
if (!playbackSettings.rememberPause) {
player.play()
}
// Ack/state save is handled in timeline change
}
override fun playNext(songs: List<Song>, ack: StateAck.PlayNext) {
val currTimeline = player.currentTimeline
val nextIndex =
if (currTimeline.isEmpty) {
C.INDEX_UNSET
} else {
currTimeline.getNextWindowIndex(
player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
}
if (nextIndex == C.INDEX_UNSET) {
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
} else {
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
}
playbackManager.ack(this, ack)
deferSave()
}
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
playbackManager.ack(this, ack)
deferSave()
}
override fun move(from: Int, to: Int, ack: StateAck.Move) {
val indices = player.unscrambleQueueIndices()
if (indices.isEmpty()) {
return
}
val trueFrom = indices[from]
val trueTo = indices[to]
when {
trueFrom > trueTo -> {
player.moveMediaItem(trueFrom, trueTo)
}
trueTo > trueFrom -> {
player.moveMediaItem(trueFrom, trueTo)
}
}
playbackManager.ack(this, ack)
deferSave()
}
override fun remove(at: Int, ack: StateAck.Remove) {
val indices = player.unscrambleQueueIndices()
if (indices.isEmpty()) {
return
}
val trueIndex = indices[at]
val songWillChange = player.currentMediaItemIndex == trueIndex
player.removeMediaItem(trueIndex)
if (songWillChange && !playbackSettings.rememberPause) {
player.play()
}
playbackManager.ack(this, ack)
deferSave()
}
override fun applySavedState(
parent: MusicParent?,
rawQueue: RawQueue,
ack: StateAck.NewPlayback?
) {
this.parent = parent
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
if (rawQueue.isShuffled) {
player.shuffleModeEnabled = true
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
} else {
player.shuffleModeEnabled = false
}
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
player.prepare()
ack?.let { playbackManager.ack(this, it) }
}
override fun reset(ack: StateAck.NewPlayback) {
player.setMediaItems(listOf())
playbackManager.ack(this, ack)
deferSave()
}
// --- PLAYER OVERRIDES ---
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
if (player.playWhenReady) {
// Mark that we have started playing so that the notification can now be posted.
logD("Player has started playing")
if (!openAudioEffectSession) {
// Convention to start an audioeffect session on play/pause rather than
// start/stop
logD("Opening audio effect session")
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = true
}
} else if (openAudioEffectSession) {
// Make sure to close the audio session when we stop playback.
logD("Closing audio effect session")
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = false
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) {
playbackManager.ack(this, StateAck.IndexMoved)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) {
goto(0)
player.pause()
}
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
// TODO: Once position also naturally drifts by some threshold, save
deferSave()
}
}
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.containsAny(
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_POSITION_DISCONTINUITY)) {
logD("Player state changed, must synchronize state")
playbackManager.ack(this, StateAck.ProgressionChanged)
}
}
override fun onPlayerError(error: PlaybackException) {
// TODO: Replace with no skipping and a notification instead
// If there's any issue, just go to the next song.
logE("Player error occurred")
logE(error.stackTraceToString())
playbackManager.next()
}
private fun broadcastAudioEffectAction(event: String) {
logD("Broadcasting AudioEffect event: $event")
context.sendBroadcast(
Intent(event)
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
}
// --- MUSICREPOSITORY METHODS ---
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
// We now have a library, see if we have anything we need to do.
logD("Library obtained, requesting action")
playbackManager.requestAction(this)
}
}
// --- PLAYBACKSETTINGS OVERRIDES ---
override fun onPauseOnRepeatChanged() {
super.onPauseOnRepeatChanged()
updatePauseOnRepeat()
}
private fun updatePauseOnRepeat() {
player.pauseAtEndOfMediaItems =
player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat
}
fun save(cb: () -> Unit) {
saveJob {
persistenceRepository.saveState(playbackManager.toSavedState())
withContext(Dispatchers.Main) { cb() }
}
}
private fun deferSave() {
saveJob {
logD("Waiting for save buffer")
delay(SAVE_BUFFER)
yield()
logD("Committing saved state")
persistenceRepository.saveState(playbackManager.toSavedState())
}
}
private fun saveJob(block: suspend () -> Unit) {
currentSaveJob?.let {
logD("Discarding prior save job")
it.cancel()
}
currentSaveJob = saveScope.launch { block() }
}
class Factory
@Inject
constructor(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val persistenceRepository: PersistenceRepository,
private val playbackSettings: PlaybackSettings,
private val commandFactory: PlaybackCommand.Factory,
private val musicRepository: MusicRepository,
private val mediaSourceFactory: MediaSource.Factory,
private val replayGainProcessor: ReplayGainAudioProcessor
) {
fun create(): ExoPlaybackStateHolder {
// Since Auxio is a music player, only specify an audio renderer to save
// battery/apk size/cache size
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
arrayOf(
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
MediaCodecAudioRenderer(
context,
MediaCodecSelector.DEFAULT,
handler,
audioListener,
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
replayGainProcessor))
}
val exoPlayer =
ExoPlayer.Builder(context, audioRenderer)
.setMediaSourceFactory(mediaSourceFactory)
// Enable automatic WakeLock support
.setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes(
// Signal that we are a music player.
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true)
.build()
return ExoPlaybackStateHolder(
context,
exoPlayer,
playbackManager,
persistenceRepository,
playbackSettings,
commandFactory,
musicRepository)
}
}
private companion object {
const val SAVE_BUFFER = 5000L
}
}

View file

@ -25,8 +25,8 @@ import android.content.Intent
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.AuxioService
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.AuxioService
import org.oxycblt.auxio.util.logD
/**

View file

@ -0,0 +1,380 @@
/*
* Copyright (c) 2024 Auxio Project
* MediaSessionPlayer.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.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.service.MediaSessionUID
import org.oxycblt.auxio.music.service.toSong
import org.oxycblt.auxio.playback.state.PlaybackCommand
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.state.ShuffleMode
import org.oxycblt.auxio.util.logD
/**
* A thin wrapper around the player instance that takes all the events I know MediaSession will send
* and routes them to PlaybackStateManager so I know that they will work the way I want it to.
* @author Alexander Capehart
*/
class MediaSessionPlayer(
player: Player,
private val playbackManager: PlaybackStateManager,
private val commandFactory: PlaybackCommand.Factory,
private val musicRepository: MusicRepository
) : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands {
return super.getAvailableCommands()
.buildUpon()
.addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS)
.build()
}
override fun isCommandAvailable(command: Int): Boolean {
// We can always skip forward and backward (this is to retain parity with the old behavior)
return super.isCommandAvailable(command) ||
command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS)
}
override fun setMediaItems(
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long
) {
// We assume the only people calling this method are going to be the MediaSession callbacks,
// since anything else (like newPlayback) will be calling directly on the player. As part
// of this, we expand the given MediaItems into the command that should be sent to the
// player.
val command =
if (mediaItems.size > 1) {
this.playMediaItemSelection(mediaItems, startIndex)
} else {
this.playSingleMediaItem(mediaItems.first())
}
requireNotNull(command) { "Invalid playback configuration" }
playbackManager.play(command)
if (startPositionMs != C.TIME_UNSET) {
playbackManager.seekTo(startPositionMs)
}
}
private fun playMediaItemSelection(
mediaItems: List<MediaItem>,
startIndex: Int
): PlaybackCommand? {
val deviceLibrary = musicRepository.deviceLibrary ?: return null
val targetSong = mediaItems.getOrNull(startIndex)?.toSong(deviceLibrary)
val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) }
var index = startIndex
if (targetSong != null) {
while (songs.getOrNull(index)?.uid != targetSong.uid) {
index--
}
}
return commandFactory.songs(songs, ShuffleMode.OFF)
}
private fun playSingleMediaItem(mediaItem: MediaItem): PlaybackCommand? {
val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null
val music: Music
var parent: MusicParent? = null
when (uid) {
is MediaSessionUID.Single -> {
music = musicRepository.find(uid.uid) ?: return null
}
is MediaSessionUID.Joined -> {
music = musicRepository.find(uid.childUid) ?: return null
parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null
}
else -> return null
}
return when (music) {
is Song -> inferSongFromParentCommand(music, parent)
is Album -> commandFactory.album(music, ShuffleMode.OFF)
is Artist -> commandFactory.artist(music, ShuffleMode.OFF)
is Genre -> commandFactory.genre(music, ShuffleMode.OFF)
is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF)
}
}
private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) =
when (parent) {
is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT)
is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT)
?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT)
is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT)
?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT)
is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT)
null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT)
}
override fun setPlayWhenReady(playWhenReady: Boolean) {
playbackManager.playing(playWhenReady)
}
override fun setRepeatMode(repeatMode: Int) {
val appRepeatMode =
when (repeatMode) {
Player.REPEAT_MODE_OFF -> RepeatMode.NONE
Player.REPEAT_MODE_ONE -> RepeatMode.TRACK
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
else -> throw IllegalStateException("Unknown repeat mode: $repeatMode")
}
playbackManager.repeatMode(appRepeatMode)
}
override fun seekToNext() = playbackManager.next()
override fun seekToNextMediaItem() = playbackManager.next()
override fun seekToPrevious() = playbackManager.prev()
override fun seekToPreviousMediaItem() = playbackManager.prev()
override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs)
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
val indices = unscrambleQueueIndices()
val fakeIndex = indices.indexOf(mediaItemIndex)
if (fakeIndex < 0) {
return
}
playbackManager.goto(fakeIndex)
if (positionMs == C.TIME_UNSET) {
return
}
playbackManager.seekTo(positionMs)
}
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) }
when {
index ==
currentTimeline.getNextWindowIndex(
currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> {
playbackManager.playNext(songs)
}
index >= mediaItemCount -> playbackManager.addToQueue(songs)
else -> error("Unsupported index $index")
}
}
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
playbackManager.shuffled(shuffleModeEnabled)
}
override fun moveMediaItem(currentIndex: Int, newIndex: Int) {
val indices = unscrambleQueueIndices()
val fakeFrom = indices.indexOf(currentIndex)
if (fakeFrom < 0) {
return
}
val fakeTo =
if (newIndex >= mediaItemCount) {
currentTimeline.getLastWindowIndex(shuffleModeEnabled)
} else {
indices.indexOf(newIndex)
}
if (fakeTo < 0) {
return
}
playbackManager.moveQueueItem(fakeFrom, fakeTo)
}
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) =
error("Multi-item queue moves are unsupported")
override fun removeMediaItem(index: Int) {
val indices = unscrambleQueueIndices()
val fakeAt = indices.indexOf(index)
if (fakeAt < 0) {
return
}
playbackManager.removeQueueItem(fakeAt)
}
override fun removeMediaItems(fromIndex: Int, toIndex: Int) =
error("Any multi-item queue removal is unsupported")
// 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.
override fun setMediaItem(mediaItem: MediaItem) = notAllowed()
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed()
override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed()
override fun setMediaItems(mediaItems: MutableList<MediaItem>) = notAllowed()
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) =
notAllowed()
override fun addMediaItem(mediaItem: MediaItem) = notAllowed()
override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
override fun addMediaItems(mediaItems: MutableList<MediaItem>) = notAllowed()
override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
override fun replaceMediaItems(
fromIndex: Int,
toIndex: Int,
mediaItems: MutableList<MediaItem>
) = notAllowed()
override fun clearMediaItems() = notAllowed()
override fun setPlaybackSpeed(speed: Float) = notAllowed()
override fun seekToDefaultPosition() = notAllowed()
override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed()
override fun seekForward() = notAllowed()
override fun seekBack() = notAllowed()
@Deprecated("Deprecated in Java") override fun next() = notAllowed()
@Deprecated("Deprecated in Java") override fun previous() = notAllowed()
@Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed()
@Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed()
override fun play() = playbackManager.playing(true)
override fun pause() = playbackManager.playing(false)
override fun prepare() = notAllowed()
override fun release() = notAllowed()
override fun stop() = notAllowed()
override fun hasNextMediaItem() = notAllowed()
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) =
notAllowed()
override fun setVolume(volume: Float) = notAllowed()
override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed()
override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed()
override fun increaseDeviceVolume(flags: Int) = notAllowed()
override fun decreaseDeviceVolume(flags: Int) = notAllowed()
@Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed()
@Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed()
@Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed()
@Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed()
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed()
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed()
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed()
override fun setVideoSurface(surface: Surface?) = notAllowed()
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed()
override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed()
override fun setVideoTextureView(textureView: TextureView?) = notAllowed()
override fun clearVideoSurface() = notAllowed()
override fun clearVideoSurface(surface: Surface?) = notAllowed()
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed()
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed()
override fun clearVideoTextureView(textureView: TextureView?) = notAllowed()
private fun notAllowed(): Nothing = error("MediaSession unexpectedly called this method")
}
fun Player.unscrambleQueueIndices(): List<Int> {
val timeline = currentTimeline
if (timeline.isEmpty) {
return emptyList()
}
val queue = mutableListOf<Int>()
// Add the active queue item.
val currentMediaItemIndex = currentMediaItemIndex
queue.add(currentMediaItemIndex)
// Fill queue alternating with next and/or previous queue items.
var firstMediaItemIndex = currentMediaItemIndex
var lastMediaItemIndex = currentMediaItemIndex
val shuffleModeEnabled = shuffleModeEnabled
while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) {
// Begin with next to have a longer tail than head if an even sized queue needs to be
// trimmed.
if (lastMediaItemIndex != C.INDEX_UNSET) {
lastMediaItemIndex =
timeline.getNextWindowIndex(
lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
if (lastMediaItemIndex != C.INDEX_UNSET) {
queue.add(lastMediaItemIndex)
}
}
if (firstMediaItemIndex != C.INDEX_UNSET) {
firstMediaItemIndex =
timeline.getPreviousWindowIndex(
firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
if (firstMediaItemIndex != C.INDEX_UNSET) {
queue.add(0, firstMediaItemIndex)
}
}
}
return queue
}

View file

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

View file

@ -51,9 +51,7 @@ interface PlaybackStateHolder {
/** The current audio session ID of the audio player. */
val audioSessionId: Int
/**
* Applies a completely new playback state to the holder.
*/
/** Applies a completely new playback state to the holder. */
fun newPlayback(command: PlaybackCommand)
/**

View file

@ -492,7 +492,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
}
}
private class QueueCommand(override val queue: List<Song>) : PlaybackCommand {
private class QueueCommand(override val queue: List<Song>) : PlaybackCommand {
override val song: Song? = null
override val parent: MusicParent? = null
override val shuffled = false

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.service
package org.oxycblt.auxio.ui
import android.content.Context
import androidx.annotation.StringRes

View file

@ -28,11 +28,11 @@ 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.state.RepeatMode
import org.oxycblt.auxio.service.AuxioService
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW