diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e807194f3..ba2a499c0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -82,7 +82,7 @@
Service handling music playback, system components, and state saving.
-->
.
+ */
+
+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 =
+ 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> =
+ Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params))
+
+ override fun onGetItem(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ mediaId: String
+ ): ListenableFuture> {
+ 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>> {
+ 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> =
+ 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, 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()
+
+ 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"
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
index f997f3b0c..e727316fb 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
@@ -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
diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt
new file mode 100644
index 000000000..050166483
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt
@@ -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 .
+ */
+
+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 {
+ throw NotImplementedError()
+ }
+
+ override fun loadBitmap(uri: Uri): ListenableFuture {
+ throw NotImplementedError()
+ }
+
+ override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture {
+ throw NotImplementedError()
+ }
+
+ override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? {
+ val deviceLibrary = musicRepository.deviceLibrary ?: return null
+ val future = SettableFuture.create()
+ 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
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
similarity index 97%
rename from app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt
rename to app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
index 402c3703e..2b1524fdf 100644
--- a/app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-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
diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt
new file mode 100644
index 000000000..fcf65715a
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt
@@ -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 .
+ */
+
+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
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt
new file mode 100644
index 000000000..00876951f
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt
@@ -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 .
+ */
+
+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>()
+
+ 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? {
+ 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? {
+ 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? {
+ 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? {
+ 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 {
+ val music = mutableListOf()
+ 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.paginate(page: Int, pageSize: Int): List? {
+ 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()
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt
new file mode 100644
index 000000000..3ae7b7edb
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt
@@ -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 .
+ */
+
+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, 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, 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
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt
index e2fe690ec..9ea0300b3 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt
@@ -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
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt
new file mode 100644
index 000000000..1ea54f92a
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt
@@ -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 .
+ */
+
+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,
+ 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,
+ 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) {
+ 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) = notAllowed()
+
+ override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) =
+ notAllowed()
+
+ override fun addMediaItem(mediaItem: MediaItem) = notAllowed()
+
+ override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
+
+ override fun addMediaItems(mediaItems: MutableList) = notAllowed()
+
+ override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
+
+ override fun replaceMediaItems(
+ fromIndex: Int,
+ toIndex: Int,
+ mediaItems: MutableList
+ ) = 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 {
+ val timeline = currentTimeline
+ if (timeline.isEmpty) {
+ return emptyList()
+ }
+ val queue = mutableListOf()
+
+ // 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
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt
new file mode 100644
index 000000000..2d73d5897
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt
@@ -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 .
+ */
+
+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)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
index cfeef8b04..8780dcdbb 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
@@ -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)
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
index fc4d5fc7c..c5231e0b0 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
@@ -492,7 +492,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
}
}
- private class QueueCommand(override val queue: List) : PlaybackCommand {
+ private class QueueCommand(override val queue: List) : PlaybackCommand {
override val song: Song? = null
override val parent: MusicParent? = null
override val shuffled = false
diff --git a/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt
deleted file mode 100644
index 8b1872f77..000000000
--- a/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt
+++ /dev/null
@@ -1,1915 +0,0 @@
-/*
- * 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 .
- */
-
-package org.oxycblt.auxio.service
-
-import android.app.Notification
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.database.ContentObserver
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.media.AudioManager
-import android.media.audiofx.AudioEffect
-import android.net.Uri
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.os.PowerManager
-import android.provider.MediaStore
-import androidx.annotation.StringRes
-import androidx.core.app.ServiceCompat
-import androidx.core.content.ContextCompat
-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.PlaybackException
-import androidx.media3.common.Player
-import androidx.media3.common.util.BitmapLoader
-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 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 com.google.common.collect.ImmutableList
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
-import com.google.common.util.concurrent.SettableFuture
-import dagger.hilt.android.AndroidEntryPoint
-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.BuildConfig
-import org.oxycblt.auxio.IntegerTable
-import org.oxycblt.auxio.R
-import org.oxycblt.auxio.image.BitmapProvider
-import org.oxycblt.auxio.list.ListSettings
-import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
-import org.oxycblt.auxio.music.IndexingProgress
-import org.oxycblt.auxio.music.IndexingState
-import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.MusicParent
-import org.oxycblt.auxio.music.MusicRepository
-import org.oxycblt.auxio.music.MusicSettings
-import org.oxycblt.auxio.music.Playlist
-import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.device.DeviceLibrary
-import org.oxycblt.auxio.music.fs.contentResolverSafe
-import org.oxycblt.auxio.music.resolveNames
-import org.oxycblt.auxio.music.user.UserLibrary
-import org.oxycblt.auxio.playback.ActionMode
-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.service.BetterShuffleOrder
-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.search.SearchEngine
-import org.oxycblt.auxio.util.getPlural
-import org.oxycblt.auxio.util.getSystemServiceCompat
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.logE
-import org.oxycblt.auxio.widgets.WidgetComponent
-import org.oxycblt.auxio.widgets.WidgetProvider
-import javax.inject.Inject
-
-// TODO: Android Auto Hookup
-// TODO: Have to clobber shuffle and repeat mode handlers
-
-@AndroidEntryPoint
-class AuxioService :
- MediaLibraryService(),
- MediaLibrarySession.Callback,
- MusicRepository.IndexingWorker,
- MusicRepository.IndexingListener,
- MusicRepository.UpdateListener,
- MusicSettings.Listener,
- PlaybackStateHolder,
- Player.Listener,
- PlaybackSettings.Listener {
- @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 serviceJob = Job()
- private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
- private var currentIndexJob: Job? = null
-
- @Inject
- lateinit var playbackManager: PlaybackStateManager
- @Inject
- lateinit var commandFactory: PlaybackCommand.Factory
- @Inject
- lateinit var playbackSettings: PlaybackSettings
- @Inject
- lateinit var persistenceRepository: PersistenceRepository
- @Inject
- lateinit var mediaSourceFactory: MediaSource.Factory
- @Inject
- lateinit var replayGainProcessor: ReplayGainAudioProcessor
- private lateinit var player: NeoPlayer
- private lateinit var mediaSession: MediaLibrarySession
- private val systemReceiver = PlaybackReceiver()
- private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO)
- private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO)
- private var currentSaveJob: Job? = null
- private var inPlayback = false
- private var openAudioEffectSession = false
-
- @Inject
- lateinit var listSettings: ListSettings
- @Inject
- lateinit var widgetComponent: WidgetComponent
- @Inject
- lateinit var bitmapLoader: NeoBitmapLoader
-
- @Inject
- lateinit var searchEngine: SearchEngine
- private var searchResultsCache = mutableMapOf()
- private var searchScope = CoroutineScope(serviceJob + Dispatchers.Default)
- private var searchJob: Job? = null
-
- override fun onCreate() {
- super.onCreate()
-
- indexingNotification = IndexingNotification(this)
- observingNotification = ObservingNotification(this)
- wakeLock =
- getSystemServiceCompat(PowerManager::class)
- .newWakeLock(
- PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService"
- )
- // 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()
-
- // 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(
- this,
- MediaCodecSelector.DEFAULT,
- handler,
- audioListener,
- AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
- replayGainProcessor
- )
- )
- }
-
- val exoPlayer =
- ExoPlayer.Builder(this, 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()
- .also { it.addListener(this) }
-
- player = NeoPlayer(
- this,
- exoPlayer,
- musicRepository,
- playbackManager,
- this,
- commandFactory,
- playbackSettings
- )
- 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) })
- mediaSession =
- MediaLibrarySession.Builder(this, player, this).setBitmapLoader(bitmapLoader).build()
- addSession(mediaSession)
- updateCustomButtons()
-
- val intentFilter =
- IntentFilter().apply {
- addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
- addAction(AudioManager.ACTION_HEADSET_PLUG)
- addAction(ACTION_INC_REPEAT_MODE)
- addAction(ACTION_INVERT_SHUFFLE)
- addAction(ACTION_SKIP_PREV)
- addAction(ACTION_PLAY_PAUSE)
- addAction(ACTION_SKIP_NEXT)
- addAction(ACTION_EXIT)
- addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
- }
-
- ContextCompat.registerReceiver(
- this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED
- )
-
- musicSettings.registerListener(this)
- musicRepository.addUpdateListener(this)
- musicRepository.addIndexingListener(this)
- musicRepository.registerWorker(this)
-
- // 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.
- playbackManager.registerStateHolder(this)
- musicRepository.addUpdateListener(this)
- playbackSettings.registerListener(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.
- wakeLock.releaseSafe()
- // Then cancel the listener-dependent components to ensure that stray reloading
- // events will not occur.
- indexerContentObserver.release()
- musicSettings.unregisterListener(this)
- musicRepository.removeUpdateListener(this)
- musicRepository.removeIndexingListener(this)
- musicRepository.unregisterWorker(this)
- // Then cancel any remaining music loading jobs.
- serviceJob.cancel()
-
- // Pause just in case this destruction was unexpected.
- playbackManager.playing(false)
- playbackManager.unregisterStateHolder(this)
- musicRepository.removeUpdateListener(this)
- playbackSettings.unregisterListener(this)
-
- serviceJob.cancel()
-
- replayGainProcessor.release()
- player.release()
- if (openAudioEffectSession) {
- // Make sure to close the audio session when we release the player.
- broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
- openAudioEffectSession = false
- }
-
- removeSession(mediaSession)
- mediaSession.release()
- player.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)
- }
-
- // --- 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_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)
- }
- }
- }
-
- // --- PLAYBACKSTATEHOLDER OVERRIDES ---
-
- override val progression: Progression
- get() =
- player.currentMediaItem?.let {
- Progression.from(
- player.playWhenReady,
- player.isPlaying,
- // The position value can be below zero or past the expected duration, make
- // sure we handle that.
- player.currentPosition
- .coerceAtLeast(0)
- .coerceAtMost(player.durationMs ?: Long.MAX_VALUE)
- )
- }
- ?: Progression.nil()
-
- 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 parent: MusicParent?
- get() = player.parent
-
- override val audioSessionId: Int
- get() = player.audioSessionId
-
- override fun resolveQueue() = player.resolveQueue()
-
- override fun newPlayback(command: PlaybackCommand) {
- player.newPlayback(command)
- updateCustomButtons()
- deferSave()
- }
-
- override fun playing(playing: Boolean) {
- player.playWhenReady = playing
- // Dispatched later once all of the changes have been accumulated
- // Playing state is not persisted, do not need to save
- }
-
- override fun repeatMode(repeatMode: RepeatMode) {
- player.repeatMode(repeatMode)
- deferSave()
- updateCustomButtons()
- }
-
- override fun seekTo(positionMs: Long) {
- player.seekTo(positionMs)
- // Dispatched later once all of the changes have been accumulated
- // Deferred save is handled on position discontinuity
- }
-
- override fun next() {
- player.seekToNext()
- // Deferred save is handled on position discontinuity
- }
-
- override fun prev() {
- player.seekToPrevious()
- // Deferred save is handled on position discontinuity
- }
-
- override fun goto(index: Int) {
- player.goto(index)
- // Deferred save is handled on position discontinuity
- }
-
- override fun shuffled(shuffled: Boolean) {
- logD("Reordering queue to $shuffled")
- player.shuffleModeEnabled = shuffled
- deferSave()
- updateCustomButtons()
- }
-
- override fun playNext(songs: List, ack: StateAck.PlayNext) {
- player.playNext(songs, ack)
- deferSave()
- }
-
- override fun addToQueue(songs: List, ack: StateAck.AddToQueue) {
- player.addToQueue(songs, ack)
- deferSave()
- }
-
- override fun move(from: Int, to: Int, ack: StateAck.Move) {
- player.move(from, to, ack)
- deferSave()
- }
-
- override fun remove(at: Int, ack: StateAck.Remove) {
- player.remove(at, ack)
- deferSave()
- }
-
- 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(workerContext, action.uri)?.let { song ->
- playbackManager.play(
- requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) {
- "Invalid playback parameters"
- })
- }
- }
- }
-
- return true
- }
-
- override fun applySavedState(
- parent: MusicParent?,
- rawQueue: RawQueue,
- ack: StateAck.NewPlayback?
- ) {
- player.applySavedState(parent, rawQueue, ack)
- }
-
- override fun reset(ack: StateAck.NewPlayback) {
- player.reset(ack)
- }
-
- // --- 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")
- inPlayback = true
- 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 occured")
- logE(error.stackTraceToString())
- playbackManager.next()
- }
-
- // --- OTHER OVERRIDES ---
-
- override fun onNotificationActionChanged() {
- super.onNotificationActionChanged()
- updateCustomButtons()
- }
-
- override fun onPauseOnRepeatChanged() {
- player.updatePauseOnRepeat()
- }
-
- override fun onMusicChanges(changes: MusicRepository.Changes) {
- if (changes.deviceLibrary) {
- if (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)
- }
- // Invalidate anything we searched prior.
- searchResultsCache.clear()
- searchJob?.cancel()
- }
- }
-
- // --- MEDIASESSION OVERRIDES ---
-
- override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
- mediaSession
-
- override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
- 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)
- }
-
- 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 onGetLibraryRoot(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- params: LibraryParams?
- ): ListenableFuture> {
- val result = LibraryResult.ofItem(ExternalUID.Category.ROOT.toMediaItem(this), params)
- return Futures.immediateFuture(result)
- }
-
- override fun onGetItem(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- mediaId: String
- ): ListenableFuture> {
- val music =
- when (val uid = ExternalUID.fromString(mediaId)) {
- is ExternalUID.Category ->
- return Futures.immediateFuture(
- LibraryResult.ofItem(uid.toMediaItem(this), null)
- )
-
- is ExternalUID.Single ->
- musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) }
-
- is ExternalUID.Joined ->
- musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) }
-
- null -> null
- }
- ?: return Futures.immediateFuture(
- LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
- )
-
- val mediaItem =
- when (music) {
- is Album -> music.toMediaItem(this, null)
- is Artist -> music.toMediaItem(this, null)
- is Genre -> music.toMediaItem(this)
- is Playlist -> music.toMediaItem(this)
- is Song -> music.toMediaItem(this, null)
- }
-
- return Futures.immediateFuture(LibraryResult.ofItem(mediaItem, null))
- }
-
- override fun onGetChildren(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- parentId: String,
- page: Int,
- pageSize: Int,
- params: LibraryParams?
- ): ListenableFuture>> {
- val deviceLibrary = musicRepository.deviceLibrary
- val userLibrary = musicRepository.userLibrary
- if (deviceLibrary == null || userLibrary == null) {
- return Futures.immediateFuture(LibraryResult.ofItemList(emptyList(), params))
- }
-
- val items =
- getMediaItemList(parentId, deviceLibrary, userLibrary)
- ?: return Futures.immediateFuture(
- LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
- )
- val paginatedItems =
- items.paginate(page, pageSize)
- ?: return Futures.immediateFuture(
- LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
- )
- val result = LibraryResult.ofItemList(paginatedItems, params)
- return Futures.immediateFuture(result)
- }
-
- private fun getMediaItemList(
- id: String,
- deviceLibrary: DeviceLibrary,
- userLibrary: UserLibrary
- ): List? {
- return when (val externalUID = ExternalUID.fromString(id)) {
- is ExternalUID.Category -> {
- when (externalUID) {
- ExternalUID.Category.ROOT ->
- listOf(
- ExternalUID.Category.SONGS,
- ExternalUID.Category.ALBUMS,
- ExternalUID.Category.ARTISTS,
- ExternalUID.Category.GENRES,
- ExternalUID.Category.PLAYLISTS
- )
- .map { it.toMediaItem(this) }
-
- ExternalUID.Category.SONGS ->
- deviceLibrary.songs.map { it.toMediaItem(this, null) }
-
- ExternalUID.Category.ALBUMS ->
- deviceLibrary.albums.map { it.toMediaItem(this, null) }
-
- ExternalUID.Category.ARTISTS ->
- deviceLibrary.artists.map { it.toMediaItem(this, null) }
-
- ExternalUID.Category.GENRES -> deviceLibrary.genres.map { it.toMediaItem(this) }
- ExternalUID.Category.PLAYLISTS ->
- userLibrary.playlists.map { it.toMediaItem(this) }
- }
- }
-
- is ExternalUID.Single -> {
- getChildMediaItems(externalUID.uid) ?: return null
- }
-
- is ExternalUID.Joined -> {
- getChildMediaItems(externalUID.childUid) ?: return null
- }
-
- null -> return null
- }
- }
-
- private fun getChildMediaItems(uid: Music.UID): List? {
- return when (val item = musicRepository.find(uid)) {
- is Album -> {
- item.songs.map { it.toMediaItem(this, item) }
- }
-
- is Artist -> {
- (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(this, item) } +
- item.songs.map { it.toMediaItem(this, item) }
- }
-
- is Genre -> {
- item.songs.map { it.toMediaItem(this, item) }
- }
-
- is Playlist -> {
- item.songs.map { it.toMediaItem(this, item) }
- }
-
- is Song,
- null -> return null
- }
- }
-
- override fun onSearch(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- query: String,
- params: LibraryParams?
- ): ListenableFuture> {
- val deviceLibrary = musicRepository.deviceLibrary
- val userLibrary = musicRepository.userLibrary
- if (deviceLibrary == null || userLibrary == null) {
- return Futures.immediateFuture(
- LibraryResult.ofError(LibraryResult.RESULT_ERROR_INVALID_STATE)
- )
- }
-
- if (query.isEmpty()) {
- return Futures.immediateFuture(LibraryResult.ofVoid())
- }
-
- val future = SettableFuture.create>()
- searchTo(query, deviceLibrary, userLibrary) { future.set(LibraryResult.ofVoid()) }
- return Futures.immediateFuture(LibraryResult.ofVoid())
- }
-
- override fun onGetSearchResult(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- query: String,
- page: Int,
- pageSize: Int,
- params: LibraryParams?
- ): ListenableFuture>> {
- val deviceLibrary = musicRepository.deviceLibrary
- val userLibrary = musicRepository.userLibrary
- if (deviceLibrary == null || userLibrary == null) {
- return Futures.immediateFuture(
- LibraryResult.ofError(LibraryResult.RESULT_ERROR_INVALID_STATE)
- )
- }
-
- if (query.isEmpty()) {
- return Futures.immediateFuture(LibraryResult.ofItemList(emptyList(), params))
- }
-
- val items = searchResultsCache[query]
- if (items != null) {
- val concatenatedItems = items.concat()
- val paginatedItems =
- concatenatedItems.paginate(page, pageSize)
- ?: return Futures.immediateFuture(
- LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
- )
- val result = LibraryResult.ofItemList(paginatedItems, params)
- return Futures.immediateFuture(result)
- }
-
- val future = SettableFuture.create>>()
- searchTo(query, deviceLibrary, userLibrary) {
- val concatenatedItems = it.concat()
- val paginatedItems = concatenatedItems.paginate(page, pageSize) ?: return@searchTo
- val result = LibraryResult.ofItemList(paginatedItems, params)
- future.set(result)
- }
-
- return future
- }
-
- private fun SearchEngine.Items.concat(): MutableList {
- val music = mutableListOf()
- if (songs != null) {
- music.addAll(songs.map { it.toMediaItem(this@AuxioService, null) })
- }
- if (albums != null) {
- music.addAll(albums.map { it.toMediaItem(this@AuxioService, null) })
- }
- if (artists != null) {
- music.addAll(artists.map { it.toMediaItem(this@AuxioService, null) })
- }
- if (genres != null) {
- music.addAll(genres.map { it.toMediaItem(this@AuxioService) })
- }
- if (playlists != null) {
- music.addAll(playlists.map { it.toMediaItem(this@AuxioService) })
- }
- return music
- }
-
- private fun searchTo(
- query: String,
- deviceLibrary: DeviceLibrary,
- userLibrary: UserLibrary,
- cb: (SearchEngine.Items) -> Unit
- ) {
- // TODO: Queue up searches rather than clobbering the last one
- searchJob?.cancel()
- searchJob =
- searchScope.launch {
- val items =
- SearchEngine.Items(
- deviceLibrary.songs,
- deviceLibrary.albums,
- deviceLibrary.artists,
- deviceLibrary.genres,
- userLibrary.playlists
- )
- val results = searchEngine.search(items, query)
- searchResultsCache[query] = results
- cb(results)
- }
- }
-
- private fun List.paginate(page: Int, pageSize: Int): List? {
- 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()
- }
-
- override fun onCustomCommand(
- session: MediaSession,
- controller: MediaSession.ControllerInfo,
- customCommand: SessionCommand,
- args: Bundle
- ): ListenableFuture =
- when (customCommand.customAction) {
- ACTION_INC_REPEAT_MODE -> {
- repeatMode(repeatMode.increment())
- Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
- }
-
- ACTION_INVERT_SHUFFLE -> {
- shuffled(!player.shuffleModeEnabled)
- Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
- }
-
- ACTION_EXIT -> {
- endSession()
- Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
- }
-
- else -> super.onCustomCommand(session, controller, customCommand, args)
- }
-
- private fun updateCustomButtons() {
- val actions = mutableListOf()
-
- 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 (player.shuffleModeEnabled) 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 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() }
- }
-
- private fun broadcastAudioEffectAction(event: String) {
- logD("Broadcasting AudioEffect event: $event")
- sendBroadcast(
- Intent(event)
- .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
- .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
- .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
- )
- }
-
- private fun endSession() {
- // This session has ended, so we need to reset this flag for when the next
- // session starts.
- saveJob {
- logD("Committing saved state")
- persistenceRepository.saveState(playbackManager.toSavedState())
- withContext(Dispatchers.Main) {
- // 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 (player.isPlaying) {
- playbackManager.playing(false)
- }
- inPlayback = false
- updateForeground(forMusic = false)
- }
- }
- }
-
- /**
- * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require
- * an active [IntentFilter] to be registered.
- */
- private inner class PlaybackReceiver : BroadcastReceiver() {
- private var initialHeadsetPlugEventHandled = false
-
- 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 ---
- ACTION_PLAY_PAUSE -> {
- logD("Received play event")
- playbackManager.playing(!playbackManager.progression.isPlaying)
- }
-
- ACTION_INC_REPEAT_MODE -> {
- logD("Received repeat mode event")
- playbackManager.repeatMode(playbackManager.repeatMode.increment())
- }
-
- ACTION_INVERT_SHUFFLE -> {
- logD("Received shuffle event")
- playbackManager.shuffled(!playbackManager.isShuffled)
- }
-
- ACTION_SKIP_PREV -> {
- logD("Received skip previous event")
- playbackManager.prev()
- }
-
- ACTION_SKIP_NEXT -> {
- logD("Received skip next event")
- playbackManager.next()
- }
-
- ACTION_EXIT -> {
- logD("Received exit event")
- playbackManager.playing(false)
- endSession()
- }
-
- WidgetProvider.ACTION_WIDGET_UPDATE -> {
- logD("Received widget update event")
- widgetComponent.update()
- }
- }
- }
-
- private fun playFromHeadsetPlug() {
- // ACTION_HEADSET_PLUG will fire when this BroadcastReciever 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)
- }
- }
- }
-
- companion object {
- const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
- const val REINDEX_DELAY_MS = 500L
- const val SAVE_BUFFER = 5000L
- 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"
- }
-}
-
-class NeoPlayer(
- val context: Context,
- val player: ExoPlayer,
- val musicRepository: MusicRepository,
- val playbackManager: PlaybackStateManager,
- val stateHolder: PlaybackStateHolder,
- val commandFactory: PlaybackCommand.Factory,
- val playbackSettings: PlaybackSettings,
-) : ForwardingPlayer(player) {
- var parent: MusicParent? = null
- private set
-
- val audioSessionId: Int
- get() = player.audioSessionId
-
- val durationMs: Long?
- get() =
- musicRepository.deviceLibrary?.let {
- currentMediaItem?.mediaMetadata?.extras?.getLong("durationMs")
- }
-
- 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 getMediaMetadata(): MediaMetadata {
- // TODO: Append parent to this for patched notification
- return player.mediaMetadata
- }
-
- override fun setMediaItems(
- mediaItems: MutableList,
- 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())
- }
- if (command != null) {
- this.newPlayback(command)
- player.seekTo(startPositionMs)
- } else {
- error("Invalid playback configuration")
- }
- }
-
- private fun playMediaItemSelection(
- mediaItems: List,
- 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 = ExternalUID.fromString(mediaItem.mediaId) ?: return null
- val music: Music
- var parent: MusicParent? = null
- when (uid) {
- is ExternalUID.Single -> {
- music = musicRepository.find(uid.uid) ?: return null
- }
-
- is ExternalUID.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 seekToNext() {
- // 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 (repeatMode != REPEAT_MODE_OFF || hasNextMediaItem()) {
- player.seekToNext()
- if (!playbackSettings.rememberPause) {
- player.play()
- }
- } else {
- player.seekTo(currentTimeline.getFirstWindowIndex(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 is handled in listener.
- }
-
- override fun seekToPrevious() {
- if (playbackSettings.rewindWithPrev) {
- player.seekToPrevious()
- } else {
- player.seekToPreviousMediaItem()
- }
- if (!playbackSettings.rememberPause) {
- player.play()
- }
- // Ack is handled in listener.
- }
-
- override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
- player.seekTo(mediaItemIndex, positionMs)
- if (!playbackSettings.rememberPause) {
- player.play()
- }
- // Ack handled in listener.
- }
-
- override fun setRepeatMode(repeatMode: Int) {
- player.setRepeatMode(repeatMode)
- this.updatePauseOnRepeat()
- playbackManager.ack(stateHolder, StateAck.RepeatModeChanged)
- }
-
- override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
- player.setShuffleModeEnabled(shuffleModeEnabled)
- if (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(stateHolder, StateAck.QueueReordered)
- }
-
- override fun addMediaItems(index: Int, mediaItems: MutableList) {
- val deviceLibrary = musicRepository.deviceLibrary ?: return
- // Sanitize possible MediaBrowser-specific items
- val items = mediaItems.mapNotNull { it.toSong(deviceLibrary)?.toMediaItem(context, null) }
- if (items.isEmpty()) {
- return
- }
- val indices = unscrambleQueueIndices()
- val fakeIndex = indices.indexOf(index)
- val ack = if (index == player.nextMediaItemIndex) {
- StateAck.PlayNext(fakeIndex + 1, items.size)
- } else if (index >= mediaItemCount) {
- // Add to queue
- StateAck.AddToQueue(mediaItemCount, items.size)
- } else {
- // I really don't want to handle any other case right now and won't until I know
- // they occured.
- return
- }
- player.addMediaItems(index, items)
- playbackManager.ack(stateHolder, ack)
- }
-
- override fun moveMediaItem(currentIndex: Int, newIndex: Int) {
- val indices = unscrambleQueueIndices()
- val fakeFrom = indices.indexOf(currentIndex)
- val fakeTo = indices.indexOf(newIndex)
- val ack = StateAck.Move(fakeFrom, fakeTo)
- player.moveMediaItem(currentIndex, newIndex)
- playbackManager.ack(stateHolder, ack)
- }
-
- override fun removeMediaItem(index: Int) {
- val indices = unscrambleQueueIndices()
- val fakeAt = indices.indexOf(index)
- player.removeMediaItem(index)
- val ack = StateAck.Remove(fakeAt)
- playbackManager.ack(stateHolder, ack)
- }
-
- fun newPlayback(command: PlaybackCommand) {
- this.parent = command.parent
- player.shuffleModeEnabled = shuffleModeEnabled
- 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(shuffleModeEnabled)
- player.seekTo(target, C.TIME_UNSET)
- player.prepare()
- player.play()
- playbackManager.ack(stateHolder, StateAck.NewPlayback)
- }
-
- fun repeatMode(repeatMode: RepeatMode) {
- this.repeatMode =
- when (repeatMode) {
- RepeatMode.NONE -> REPEAT_MODE_OFF
- RepeatMode.ALL -> REPEAT_MODE_ALL
- RepeatMode.TRACK -> REPEAT_MODE_ONE
- }
- }
-
- fun goto(index: Int) {
- val indices = unscrambleQueueIndices()
- if (indices.isEmpty()) {
- return
- }
-
- val trueIndex = indices[index]
- this.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic
- }
-
- fun playNext(songs: List, ack: StateAck.PlayNext) {
- val currTimeline = player.currentTimeline
- val nextIndex =
- if (currTimeline.isEmpty) {
- C.INDEX_UNSET
- } else {
- currTimeline.getNextWindowIndex(
- currentMediaItemIndex, REPEAT_MODE_OFF, 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(stateHolder, ack)
- }
-
- fun addToQueue(songs: List, ack: StateAck.AddToQueue) {
- player.addMediaItems(songs.map { it.toMediaItem(context, null) })
- playbackManager.ack(stateHolder, ack)
- }
-
- fun move(from: Int, to: Int, ack: StateAck.Move) {
- val indices = unscrambleQueueIndices()
- if (indices.isEmpty()) {
- return
- }
-
- val trueFrom = indices[from]
- val trueTo = indices[to]
-
- when {
- trueFrom > trueTo -> {
- player.moveMediaItem(trueFrom, trueTo)
- player.moveMediaItem(trueTo + 1, trueFrom)
- }
-
- trueTo > trueFrom -> {
- player.moveMediaItem(trueFrom, trueTo)
- player.moveMediaItem(trueTo - 1, trueFrom)
- }
- }
- playbackManager.ack(stateHolder, ack)
- }
-
- fun remove(at: Int, ack: StateAck.Remove) {
- val indices = unscrambleQueueIndices()
- if (indices.isEmpty()) {
- return
- }
-
- val trueIndex = indices[at]
- val songWillChange = currentMediaItemIndex == trueIndex
- removeMediaItem(trueIndex)
- if (songWillChange && !playbackSettings.rememberPause) {
- play()
- }
- playbackManager.ack(stateHolder, ack)
- }
-
- 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(stateHolder, it) }
- }
-
- fun reset(ack: StateAck.NewPlayback) {
- player.setMediaItems(listOf())
- playbackManager.ack(stateHolder, ack)
- }
-
- fun updatePauseOnRepeat() {
- player.pauseAtEndOfMediaItems =
- repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat
- }
-
- 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 (shuffleModeEnabled) {
- unscrambleQueueIndices()
- } else {
- emptyList()
- }
- return RawQueue(
- heap.mapNotNull { it.toSong(deviceLibrary) },
- shuffledMapping,
- player.currentMediaItemIndex
- )
- }
-
- private fun unscrambleQueueIndices(): List {
- val timeline = currentTimeline
- if (timeline.isEmpty) {
- return emptyList()
- }
- val queue = mutableListOf()
-
- // 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
- }
-}
-
-private fun ExternalUID.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()
-}
-
-private fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
- val externalUID =
- if (parent == null) {
- ExternalUID.Single(uid)
- } else {
- ExternalUID.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", externalUID.toString())
- putLong("durationMs", durationMs)
- })
- .build()
- return MediaItem.Builder()
- .setUri(uri)
- .setMediaId(externalUID.toString())
- .setMediaMetadata(metadata)
- .build()
-}
-
-private fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem {
- val externalUID =
- if (parent == null) {
- ExternalUID.Single(uid)
- } else {
- ExternalUID.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", externalUID.toString()) })
- .build()
- return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build()
-}
-
-private fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem {
- val externalUID =
- if (parent == null) {
- ExternalUID.Single(uid)
- } else {
- ExternalUID.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", externalUID.toString()) })
- .build()
- return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build()
-}
-
-private fun Genre.toMediaItem(context: Context): MediaItem {
- val externalUID = ExternalUID.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", externalUID.toString()) })
- .build()
- return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build()
-}
-
-private fun Playlist.toMediaItem(context: Context): MediaItem {
- val externalUID = ExternalUID.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", externalUID.toString()) })
- .build()
- return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build()
-}
-
-private fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
- val uid = ExternalUID.fromString(mediaId) ?: return null
- return when (uid) {
- is ExternalUID.Single -> {
- deviceLibrary.findSong(uid.uid)
- }
-
- is ExternalUID.Joined -> {
- deviceLibrary.findSong(uid.childUid)
- }
-
- is ExternalUID.Category -> null
- }
-}
-
-class NeoBitmapLoader
-@Inject
-constructor(
- private val musicRepository: MusicRepository,
- private val bitmapProvider: BitmapProvider
-) : BitmapLoader {
- override fun decodeBitmap(data: ByteArray): ListenableFuture {
- TODO("Not yet implemented")
- }
-
- override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture {
- TODO("Not yet implemented")
- }
-
- override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? {
- val deviceLibrary = musicRepository.deviceLibrary ?: return null
- val future = SettableFuture.create()
- val song =
- when (val uid = metadata.extras?.getString("uid")?.let { ExternalUID.fromString(it) }) {
- is ExternalUID.Single -> deviceLibrary.findSong(uid.uid)
- is ExternalUID.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
- }
-}
-
-sealed interface ExternalUID {
- enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) :
- ExternalUID {
- 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) : ExternalUID {
- override fun toString() = "$ID_ITEM:$uid"
- }
-
- data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : ExternalUID {
- 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): ExternalUID? {
- 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
- }
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt
similarity index 98%
rename from app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt
rename to app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt
index 7bcd6118a..df1c1e604 100644
--- a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.service
+package org.oxycblt.auxio.ui
import android.content.Context
import androidx.annotation.StringRes
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
index bc99380ea..9ffd7631f 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
@@ -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