playback: rearchitecture around media3 (prototype)

Nowhere near complete in any capacity.
This commit is contained in:
Alexander Capehart 2024-04-07 23:25:06 -06:00
parent d27d99be53
commit 04ea6834fb
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
15 changed files with 1500 additions and 1747 deletions

View file

@ -126,6 +126,7 @@ dependencies {
// --- THIRD PARTY ---
// Exoplayer (Vendored)
implementation project(":media-lib-session")
implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"

View file

@ -85,8 +85,13 @@
android:name=".service.AuxioService"
android:foregroundServiceType="mediaPlayback"
android:icon="@mipmap/ic_launcher"
android:exported="false"
android:roundIcon="@mipmap/ic_launcher" />
android:exported="true"
android:roundIcon="@mipmap/ic_launcher">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<!--
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.

View file

@ -133,4 +133,7 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124
const val PLAYER_COMMAND_INC_REPEAT_MODE = 0xA125
const val PLAYER_COMMAND_TOGGLE_SHUFFLE = 0xA126
const val PLAYER_COMMAND_EXIT = 0xA127
}

View file

@ -206,7 +206,7 @@ interface MusicRepository {
/** A persistent worker that can load music in the background. */
interface IndexingWorker {
/** A [Context] required to read device storage */
val applicationContext: Context
val workerContext: Context
/** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope
@ -343,7 +343,7 @@ constructor(
}
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
worker.scope.launch { indexWrapper(worker.applicationContext, this, withCache) }
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
try {

View file

@ -37,9 +37,9 @@ import org.oxycblt.auxio.util.positiveOrNull
* @author Alexander Capehart (OxygenCobalt)
*/
class Date private constructor(private val tokens: List<Int>) : Comparable<Date> {
private val year = tokens[0]
private val month = tokens.getOrNull(1)
private val day = tokens.getOrNull(2)
val year = tokens[0]
val month = tokens.getOrNull(1)
val day = tokens.getOrNull(2)
private val hour = tokens.getOrNull(3)
private val minute = tokens.getOrNull(4)
private val second = tokens.getOrNull(5)

View file

@ -1,274 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* IndexerServiceFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.service
import android.app.Service
import android.content.Context
import android.database.ContentObserver
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.MediaStore
import coil.ImageLoader
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.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.fs.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ServiceFragment
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
/**
* A [Service] that manages the background music loading process.
*
* Loading music is a time-consuming process that would likely be killed by the system before it
* could complete if ran anywhere else. So, this [Service] manages the music loading process as an
* instance of [MusicRepository.IndexingWorker].
*
* This [Service] also handles automatic rescanning, as that is a similarly long-running background
* operation that would be unsuitable elsewhere in the app.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Unify with PlaybackService as part of the service independence project
*/
class IndexerServiceFragment
@Inject
constructor(
val imageLoader: ImageLoader,
val musicRepository: MusicRepository,
val musicSettings: MusicSettings,
val playbackManager: PlaybackStateManager
) :
ServiceFragment(),
MusicRepository.IndexingWorker,
MusicRepository.IndexingListener,
MusicRepository.UpdateListener,
MusicSettings.Listener {
private val serviceJob = Job()
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
private lateinit var indexingNotification: IndexingNotification
private lateinit var observingNotification: ObservingNotification
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var indexerContentObserver: SystemContentObserver
override fun onCreate(context: Context) {
indexingNotification = IndexingNotification(context)
observingNotification = ObservingNotification(context)
wakeLock =
context
.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()
musicSettings.registerListener(this)
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this)
musicRepository.registerWorker(this)
logD("Service created.")
}
override fun 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()
}
// --- CONTROLLER CALLBACKS ---
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 applicationContext: Context
get() = context
override val scope = indexScope
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
savedState.copy(
heap =
savedState.heap.map { song ->
song?.let { deviceLibrary.findSong(it.uid) }
}),
true)
}
}
override fun onIndexingStateChanged() {
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
updateActiveSession(state.progress)
} else {
updateIdleSession()
}
}
// --- INTERNAL ---
private fun updateActiveSession(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)
}
// Make sure we can keep the CPU on while loading music
wakeLock.acquireSafe()
}
private fun updateIdleSession() {
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)
} else {
// Not observing and done loading, exit foreground.
logD("Exiting foreground")
stopForeground()
}
// 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")
updateIdleSession()
}
}
/**
* 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 {
context.contentResolverSafe.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
}
/**
* Release this instance, preventing it from further observing the database and cancelling
* any pending update events.
*/
fun release() {
handler.removeCallbacks(this)
context.contentResolverSafe.unregisterContentObserver(this)
}
override fun onChange(selfChange: Boolean) {
// Batch rapid-fire updates to the library into a single call to run after 500ms
handler.removeCallbacks(this)
handler.postDelayed(this, REINDEX_DELAY_MS)
}
override fun run() {
// Check here if we should even start a reindex. This is much less bug-prone than
// registering and de-registering this component as this setting changes.
if (musicSettings.shouldBeObserving) {
logD("MediaStore changed, starting re-index")
requestIndex(true)
}
}
}
private companion object {
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
const val REINDEX_DELAY_MS = 500L
}
}

View file

@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.AuxioService
import org.oxycblt.auxio.util.logD
/**
@ -47,7 +48,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
// wrong action at the wrong time will result in the app crashing, and there is
// nothing I can do about it.
logD("Delivering media button intent $intent")
intent.component = ComponentName(context, PlaybackServiceFragment::class.java)
intent.component = ComponentName(context, AuxioService::class.java)
ContextCompat.startForegroundService(context, intent)
}
}

View file

@ -1,482 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* MediaSessionComponent.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.service
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.media.session.MediaButtonReceiver
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD
/**
* A component that mirrors the current playback state into the [MediaSessionCompat] and
* [NotificationComponent].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MediaSessionComponent
@Inject
constructor(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val bitmapProvider: BitmapProvider,
private val imageSettings: ImageSettings
) :
MediaSessionCompat.Callback(),
PlaybackStateManager.Listener,
ImageSettings.Listener,
PlaybackSettings.Listener {
private val mediaSession =
MediaSessionCompat(context, context.packageName).apply {
isActive = true
setQueueTitle(context.getString(R.string.lbl_queue))
}
private val notification = NotificationComponent(context, mediaSession.sessionToken)
private var listener: Listener? = null
init {
playbackManager.addListener(this)
playbackSettings.registerListener(this)
imageSettings.registerListener(this)
mediaSession.setCallback(this)
}
/**
* Forward a system media button [Intent] to the [MediaSessionCompat].
*
* @param intent The [Intent.ACTION_MEDIA_BUTTON] [Intent] to forward.
*/
fun handleMediaButtonIntent(intent: Intent) {
logD("Forwarding $intent to MediaButtonReciever")
MediaButtonReceiver.handleIntent(mediaSession, intent)
}
/**
* Register a [Listener] for notification updates to this service.
*
* @param listener The [Listener] to register.
*/
fun registerListener(listener: Listener) {
this.listener = listener
}
/**
* Release this instance, closing the [MediaSessionCompat] and preventing any further updates to
* the [NotificationComponent].
*/
fun release() {
listener = null
bitmapProvider.release()
playbackSettings.unregisterListener(this)
imageSettings.unregisterListener(this)
playbackManager.removeListener(this)
mediaSession.apply {
isActive = false
release()
}
}
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
override fun onIndexMoved(index: Int) {
updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
invalidateSessionState()
}
override fun onQueueChanged(queue: List<Song>, index: Int, change: QueueChange) {
updateQueue(queue)
when (change.type) {
// Nothing special to do with mapping changes.
QueueChange.Type.MAPPING -> {}
// Index changed, ensure playback state's index changes.
QueueChange.Type.INDEX -> invalidateSessionState()
// Song changed, ensure metadata changes.
QueueChange.Type.SONG ->
updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
}
}
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
updateQueue(queue)
invalidateSessionState()
mediaSession.setShuffleMode(
if (isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
override fun onNewPlayback(
parent: MusicParent?,
queue: List<Song>,
index: Int,
isShuffled: Boolean
) {
updateMediaMetadata(playbackManager.currentSong, parent)
updateQueue(queue)
invalidateSessionState()
}
override fun onProgressionChanged(progression: Progression) {
invalidateSessionState()
notification.updatePlaying(playbackManager.progression.isPlaying)
if (!bitmapProvider.isBusy) {
listener?.onPostNotification(notification)
}
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
mediaSession.setRepeatMode(
when (repeatMode) {
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
})
invalidateSecondaryAction()
}
// --- SETTINGS OVERRIDES ---
override fun onImageSettingsChanged() {
// Need to reload the metadata cover.
updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
}
override fun onNotificationActionChanged() {
// Need to re-load the action shown in the notification.
invalidateSecondaryAction()
}
// --- MEDIASESSION OVERRIDES ---
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
super.onPlayFromMediaId(mediaId, extras)
// STUB: Unimplemented, no media browser
}
override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
super.onPlayFromUri(uri, extras)
// STUB: Unimplemented, no media browser
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
super.onPlayFromSearch(query, extras)
// STUB: Unimplemented, no media browser
}
override fun onAddQueueItem(description: MediaDescriptionCompat?) {
super.onAddQueueItem(description)
// STUB: Unimplemented
}
override fun onRemoveQueueItem(description: MediaDescriptionCompat?) {
super.onRemoveQueueItem(description)
// STUB: Unimplemented
}
override fun onPlay() {
playbackManager.playing(true)
}
override fun onPause() {
playbackManager.playing(false)
}
override fun onSkipToNext() {
playbackManager.next()
}
override fun onSkipToPrevious() {
playbackManager.prev()
}
override fun onSeekTo(position: Long) {
playbackManager.seekTo(position)
}
override fun onFastForward() {
playbackManager.next()
}
override fun onRewind() {
playbackManager.rewind()
playbackManager.playing(true)
}
override fun onSetRepeatMode(repeatMode: Int) {
playbackManager.repeatMode(
when (repeatMode) {
PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL
PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL
PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK
else -> RepeatMode.NONE
})
}
override fun onSetShuffleMode(shuffleMode: Int) {
playbackManager.shuffled(
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP)
}
override fun onSkipToQueueItem(id: Long) {
playbackManager.goto(id.toInt())
}
override fun onCustomAction(action: String?, extras: Bundle?) {
super.onCustomAction(action, extras)
// Service already handles intents from the old notification actions, easier to
// plug into that system.
context.sendBroadcast(Intent(action))
}
override fun onStop() {
// Get the service to shut down with the ACTION_EXIT intent
context.sendBroadcast(Intent(PlaybackServiceFragment.ACTION_EXIT))
}
// --- INTERNAL ---
/**
* Upload a new [MediaMetadataCompat] based on the current playback state to the
* [MediaSessionCompat] and [NotificationComponent].
*
* @param song The current [Song] to create the [MediaMetadataCompat] from, or null if no [Song]
* is currently playing.
* @param parent The current [MusicParent] to create the [MediaMetadataCompat] from, or null if
* playback is currently occuring from all songs.
*/
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
logD("Updating media metadata to $song with $parent")
if (song == null) {
// Nothing playing, reset the MediaSession and close the notification.
logD("Nothing playing, resetting media session")
mediaSession.setMetadata(emptyMetadata)
return
}
// Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
// several times.
val title = song.name.resolve(context)
val artist = song.artists.resolveNames(context)
val builder =
MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context))
// Note: We would leave the artist field null if it didn't exist and let downstream
// consumers handle it, but that would break the notification display.
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
song.album.artists.resolveNames(context))
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context))
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
parent?.run { name.resolve(context) }
?: context.getString(R.string.lbl_all_songs))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
// These fields are nullable and so we must check first before adding them to the fields.
song.track?.let {
logD("Adding track information")
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
}
song.disc?.let {
logD("Adding disc information")
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong())
}
song.date?.let {
logD("Adding date information")
builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString())
}
// We are normally supposed to use URIs for album art, but that removes some of the
// nice things we can do like square cropping or high quality covers. Instead,
// we load a full-size bitmap into the media session and take the performance hit.
bitmapProvider.load(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
logD("Bitmap loaded, applying media session and posting notification")
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
val metadata = builder.build()
mediaSession.setMetadata(metadata)
notification.updateMetadata(metadata)
listener?.onPostNotification(notification)
}
})
}
/**
* Upload a new queue to the [MediaSessionCompat].
*
* @param queue The current queue to upload.
*/
private fun updateQueue(queue: List<Song>) {
val queueItems =
queue.mapIndexed { i, song ->
val description =
MediaDescriptionCompat.Builder()
// Media ID should not be the item index but rather the UID,
// as it's used to request a song to be played from the queue.
.setMediaId(song.uid.toString())
.setTitle(song.name.resolve(context))
.setSubtitle(song.artists.resolveNames(context))
// Since we usually have to load many songs into the queue, use the
// MediaStore URI instead of loading a bitmap.
.setIconUri(song.album.coverUri.mediaStore)
.setMediaUri(song.uri)
.build()
// Store the item index so we can then use the analogous index in the
// playback state.
MediaSessionCompat.QueueItem(description, i.toLong())
}
logD("Uploading ${queueItems.size} songs to MediaSession queue")
mediaSession.setQueue(queueItems)
}
/** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */
private fun invalidateSessionState() {
logD("Updating media session playback state")
val state =
// InternalPlayer.State handles position/state information.
playbackManager.progression
.intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here.
.setActiveQueueItemId(playbackManager.index.toLong())
// Android 13+ relies on custom actions in the notification.
// Add the secondary action (either repeat/shuffle depending on the configuration)
val secondaryAction =
when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE -> {
logD("Using shuffle MediaSession action")
PlaybackStateCompat.CustomAction.Builder(
PlaybackServiceFragment.ACTION_INVERT_SHUFFLE,
context.getString(R.string.desc_shuffle),
if (playbackManager.isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
})
}
else -> {
logD("Using repeat mode MediaSession action")
PlaybackStateCompat.CustomAction.Builder(
PlaybackServiceFragment.ACTION_INC_REPEAT_MODE,
context.getString(R.string.desc_change_repeat),
playbackManager.repeatMode.icon)
}
}
state.addCustomAction(secondaryAction.build())
// Add the exit action so the service can be closed
val exitAction =
PlaybackStateCompat.CustomAction.Builder(
PlaybackServiceFragment.ACTION_EXIT,
context.getString(R.string.desc_exit),
R.drawable.ic_close_24)
.build()
state.addCustomAction(exitAction)
mediaSession.setPlaybackState(state.build())
}
/** Invalidate the "secondary" action (i.e shuffle/repeat mode). */
private fun invalidateSecondaryAction() {
logD("Invalidating secondary action")
invalidateSessionState()
when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE -> {
logD("Using shuffle notification action")
notification.updateShuffled(playbackManager.isShuffled)
}
else -> {
logD("Using repeat mode notification action")
notification.updateRepeatMode(playbackManager.repeatMode)
}
}
if (!bitmapProvider.isBusy) {
logD("Not loading a bitmap, post the notification")
listener?.onPostNotification(notification)
}
}
/** An interface for handling changes in the notification configuration. */
interface Listener {
/**
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
*
* @param notification The new [NotificationComponent].
*/
fun onPostNotification(notification: NotificationComponent)
}
companion object {
private val emptyMetadata = MediaMetadataCompat.Builder().build()
private const val ACTIONS =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SET_REPEAT_MODE or
PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or
PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_STOP
}
}

View file

@ -1,162 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* NotificationComponent.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.service
import android.annotation.SuppressLint
import android.content.Context
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.service.ForegroundServiceNotification
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* The playback notification component. Due to race conditions regarding notification updates, this
* component is not self-sufficient. [MediaSessionComponent] should be used instead of manage it.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@SuppressLint("RestrictedApi")
class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) :
ForegroundServiceNotification(context, CHANNEL_INFO) {
init {
setSmallIcon(R.drawable.ic_auxio_24)
setCategory(NotificationCompat.CATEGORY_TRANSPORT)
setShowWhen(false)
setSilent(true)
setContentIntent(context.newMainPendingIntent())
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
addAction(buildRepeatAction(context, RepeatMode.NONE))
addAction(
buildAction(
context, PlaybackServiceFragment.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24))
addAction(buildPlayPauseAction(context, true))
addAction(
buildAction(
context, PlaybackServiceFragment.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24))
addAction(buildAction(context, PlaybackServiceFragment.ACTION_EXIT, R.drawable.ic_close_24))
setStyle(MediaStyle().setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3))
}
override val code: Int
get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE
// --- STATE FUNCTIONS ---
/**
* Update the currently shown metadata in this notification.
*
* @param metadata The [MediaMetadataCompat] to display in this notification.
*/
fun updateMetadata(metadata: MediaMetadataCompat) {
logD("Updating shown metadata")
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION))
}
/**
* Update the playing state shown in this notification.
*
* @param isPlaying Whether playback should be indicated as ongoing or paused.
*/
fun updatePlaying(isPlaying: Boolean) {
logD("Updating playing state: $isPlaying")
mActions[2] = buildPlayPauseAction(context, isPlaying)
}
/**
* Update the secondary action in this notification to show the current [RepeatMode].
*
* @param repeatMode The current [RepeatMode].
*/
fun updateRepeatMode(repeatMode: RepeatMode) {
logD("Applying repeat mode action: $repeatMode")
mActions[0] = buildRepeatAction(context, repeatMode)
}
/**
* Update the secondary action in this notification to show the current shuffle state.
*
* @param isShuffled Whether the queue is currently shuffled or not.
*/
fun updateShuffled(isShuffled: Boolean) {
logD("Applying shuffle action: $isShuffled")
mActions[0] = buildShuffleAction(context, isShuffled)
}
// --- NOTIFICATION ACTION BUILDERS ---
private fun buildPlayPauseAction(
context: Context,
isPlaying: Boolean
): NotificationCompat.Action {
val drawableRes =
if (isPlaying) {
R.drawable.ic_pause_24
} else {
R.drawable.ic_play_24
}
return buildAction(context, PlaybackServiceFragment.ACTION_PLAY_PAUSE, drawableRes)
}
private fun buildRepeatAction(
context: Context,
repeatMode: RepeatMode
): NotificationCompat.Action {
return buildAction(context, PlaybackServiceFragment.ACTION_INC_REPEAT_MODE, repeatMode.icon)
}
private fun buildShuffleAction(
context: Context,
isShuffled: Boolean
): NotificationCompat.Action {
val drawableRes =
if (isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
}
return buildAction(context, PlaybackServiceFragment.ACTION_INVERT_SHUFFLE, drawableRes)
}
private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) =
NotificationCompat.Action.Builder(
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
.build()
private companion object {
/** Notification channel used by solely the playback notification. */
val CHANNEL_INFO =
ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
nameRes = R.string.lbl_playback)
}
}

View file

@ -1,782 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* PlaybackServiceFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.media.audiofx.AudioEffect
import androidx.core.content.ContextCompat
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 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.BuildConfig
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
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.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.StateAck
import org.oxycblt.auxio.service.ServiceFragment
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.auxio.widgets.WidgetProvider
/**
* A service that manages the system-side aspects of playback, such as:
* - The single [ExoPlayer] instance.
* - The Media Notification
* - Headset management
* - Widgets
*
* This service is headless and does not manage the playback state. Moreover, the player instance is
* not the source of truth for the state, but rather the means to control system-side playback. Both
* of those tasks are what [PlaybackStateManager] is for.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Refactor lifecycle to run completely headless (i.e no activity needed)
* TODO: Android Auto
*/
class PlaybackServiceFragment
@Inject
constructor(
val mediaSourceFactory: MediaSource.Factory,
val replayGainProcessor: ReplayGainAudioProcessor,
val mediaSessionComponent: MediaSessionComponent,
val widgetComponent: WidgetComponent,
val playbackManager: PlaybackStateManager,
val playbackSettings: PlaybackSettings,
val persistenceRepository: PersistenceRepository,
val listSettings: ListSettings,
val musicRepository: MusicRepository
) :
ServiceFragment(),
Player.Listener,
PlaybackStateHolder,
PlaybackSettings.Listener,
MediaSessionComponent.Listener,
MusicRepository.UpdateListener {
// Player components
private lateinit var player: ExoPlayer
// System backend components
private val systemReceiver = PlaybackReceiver()
// Stat
private var hasPlayed = false
private var openAudioEffectSession = false
// Coroutines
private val serviceJob = Job()
private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO)
private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO)
private var currentSaveJob: Job? = null
// --- SERVICE OVERRIDES ---
override fun onCreate(context: Context) {
// 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))
}
player =
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()
.also { it.addListener(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)
mediaSessionComponent.registerListener(this)
playbackSettings.registerListener(this)
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(
context, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED)
logD("Service created")
}
override fun onStartCommand(intent: Intent) {
// Forward system media button sent by MediaButtonReceiver to MediaSessionComponent
if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
mediaSessionComponent.handleMediaButtonIntent(intent)
}
}
override fun onTaskRemoved() {
if (!playbackManager.progression.isPlaying) {
playbackManager.playing(false)
endSession()
}
}
override fun onDestroy() {
// Pause just in case this destruction was unexpected.
playbackManager.playing(false)
playbackManager.unregisterStateHolder(this)
musicRepository.removeUpdateListener(this)
playbackSettings.unregisterListener(this)
context.unregisterReceiver(systemReceiver)
serviceJob.cancel()
widgetComponent.release()
mediaSessionComponent.release()
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
}
logD("Service destroyed")
}
// --- 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(it.song.durationMs))
}
?: 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 var parent: MusicParent? = null
override fun resolveQueue(): RawQueue {
val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).song }
val shuffledMapping =
if (player.shuffleModeEnabled) {
player.unscrambleQueueIndices()
} else {
emptyList()
}
return RawQueue(heap, shuffledMapping, player.currentMediaItemIndex)
}
override val audioSessionId: Int
get() = player.audioSessionId
override fun newPlayback(
queue: List<Song>,
start: Song?,
parent: MusicParent?,
shuffled: Boolean
) {
this.parent = parent
player.shuffleModeEnabled = shuffled
player.setMediaItems(queue.map { it.toMediaItem() })
val startIndex =
start
?.let { queue.indexOf(start) }
.also { check(it != -1) { "Start song not in queue" } }
if (shuffled) {
player.setShuffleOrder(BetterShuffleOrder(queue.size, startIndex ?: -1))
}
val target =
startIndex ?: player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled)
player.seekTo(target, C.TIME_UNSET)
player.prepare()
player.play()
playbackManager.ack(this, StateAck.NewPlayback)
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 =
when (repeatMode) {
RepeatMode.NONE -> Player.REPEAT_MODE_OFF
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
}
playbackManager.ack(this, StateAck.RepeatModeChanged)
updatePauseOnRepeat()
deferSave()
}
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() {
// 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 {
goto(0)
// 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()
}
}
playbackManager.ack(this, StateAck.IndexMoved)
// Deferred save is handled on position discontinuity
}
override fun prev() {
if (playbackSettings.rewindWithPrev) {
player.seekToPrevious()
} else {
player.seekToPreviousMediaItem()
}
if (!playbackSettings.rememberPause) {
player.play()
}
playbackManager.ack(this, StateAck.IndexMoved)
// Deferred save is handled on position discontinuity
}
override fun goto(index: Int) {
val indices = player.unscrambleQueueIndices()
if (indices.isEmpty()) {
return
}
val trueIndex = indices[index]
player.seekTo(trueIndex, C.TIME_UNSET)
if (!playbackSettings.rememberPause) {
player.play()
}
playbackManager.ack(this, StateAck.IndexMoved)
// Deferred save is handled on position discontinuity
}
override fun shuffled(shuffled: Boolean) {
logD("Reordering queue to $shuffled")
player.shuffleModeEnabled = shuffled
if (shuffled) {
// 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 playNext(songs: List<Song>, ack: StateAck.PlayNext) {
val currTimeline = player.currentTimeline
val nextIndex =
if (currTimeline.isEmpty) {
C.INDEX_UNSET
} else {
currTimeline.getNextWindowIndex(
player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
}
if (nextIndex == C.INDEX_UNSET) {
player.addMediaItems(songs.map { it.toMediaItem() })
} else {
player.addMediaItems(nextIndex, songs.map { it.toMediaItem() })
}
playbackManager.ack(this, ack)
deferSave()
}
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
player.addMediaItems(songs.map { it.toMediaItem() })
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)
player.moveMediaItem(trueTo + 1, trueFrom)
}
trueTo > trueFrom -> {
player.moveMediaItem(trueFrom, trueTo)
player.moveMediaItem(trueTo - 1, trueFrom)
}
}
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 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(
null, null, listSettings.songSort.songs(deviceLibrary.songs), true)
}
// 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.applicationContext, action.uri)?.let { song ->
playbackManager.play(
song,
null,
listSettings.songSort.songs(deviceLibrary.songs),
player.shuffleModeEnabled && playbackSettings.keepShuffle)
}
}
}
return true
}
override fun applySavedState(
parent: MusicParent?,
rawQueue: RawQueue,
ack: StateAck.NewPlayback?
) {
this.parent = parent
player.setMediaItems(rawQueue.heap.map { it.toMediaItem() })
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(emptyList())
playbackManager.ack(this, 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.
hasPlayed = true
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 occured")
logE(error.stackTraceToString())
playbackManager.next()
}
// --- OTHER OVERRIDES ---
override fun onPauseOnRepeatChanged() {
updatePauseOnRepeat()
}
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)
}
}
override fun onPostNotification(notification: NotificationComponent) {
// Do not post the notification if playback hasn't started yet. This prevents errors
// where changing a setting would cause the notification to appear in an unfriendly
// manner.
if (hasPlayed) {
logD("Played before, starting foreground state")
startForeground(notification)
}
}
// --- PLAYER MANAGEMENT ---
private fun updatePauseOnRepeat() {
player.pauseAtEndOfMediaItems =
playbackManager.repeatMode == RepeatMode.TRACK && playbackSettings.pauseOnRepeat
}
private fun ExoPlayer.unscrambleQueueIndices(): List<Int> {
val timeline = currentTimeline
if (timeline.isEmpty) {
return emptyList()
}
val queue = mutableListOf<Int>()
// Add the active queue item.
val currentMediaItemIndex = currentMediaItemIndex
queue.add(currentMediaItemIndex)
// Fill queue alternating with next and/or previous queue items.
var firstMediaItemIndex = currentMediaItemIndex
var lastMediaItemIndex = currentMediaItemIndex
val shuffleModeEnabled = shuffleModeEnabled
while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) {
// Begin with next to have a longer tail than head if an even sized queue needs to be
// trimmed.
if (lastMediaItemIndex != C.INDEX_UNSET) {
lastMediaItemIndex =
timeline.getNextWindowIndex(
lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
if (lastMediaItemIndex != C.INDEX_UNSET) {
queue.add(lastMediaItemIndex)
}
}
if (firstMediaItemIndex != C.INDEX_UNSET) {
firstMediaItemIndex =
timeline.getPreviousWindowIndex(
firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
if (firstMediaItemIndex != C.INDEX_UNSET) {
queue.add(0, firstMediaItemIndex)
}
}
}
return queue
}
private fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build()
private val MediaItem.song: Song
get() = requireNotNull(localConfiguration).tag as Song
// --- OTHER FUNCTIONS ---
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")
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))
}
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) {
hasPlayed = false
playbackManager.playing(false)
stopForeground()
}
}
}
}
/**
* 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 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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.service
package org.oxycblt.auxio.service
import android.content.Context
import android.os.SystemClock
@ -25,7 +25,6 @@ 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.service.ForegroundServiceNotification
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
@ -114,6 +113,20 @@ class ObservingNotification(context: Context) :
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
}
class FakeNotification(context: Context) : ForegroundServiceNotification(context, indexerChannel) {
init {
setSmallIcon(R.drawable.ic_auxio_24)
setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false)
setSilent(true)
setContentIntent(context.newMainPendingIntent())
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
}
override val code: Int
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
}
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel =
ForegroundServiceNotification.ChannelInfo(

View file

@ -22,22 +22,22 @@ import android.content.Context
import android.content.Intent
abstract class ServiceFragment {
private var handle: AuxioService? = null
// private var handle: AuxioService? = null
protected val context: Context
get() = requireNotNull(handle)
get() = throw NotImplementedError()
var notification: ForegroundServiceNotification? = null
private set
fun attach(handle: AuxioService) {
this.handle = handle
throw NotImplementedError()
onCreate(handle)
}
fun release() {
notification = null
handle = null
throw NotImplementedError()
onDestroy()
}
@ -59,11 +59,11 @@ abstract class ServiceFragment {
protected fun startForeground(notification: ForegroundServiceNotification) {
this.notification = notification
requireNotNull(handle).refreshForeground()
// requireNotNull(handle).refreshForeground()
}
protected fun stopForeground() {
this.notification = null
requireNotNull(handle).refreshForeground()
// requireNotNull(handle).refreshForeground()
}
}

View file

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

2
media

@ -1 +1 @@
Subproject commit e585deaa94cc679ab4fd0a653cc1bf67abb54b7e
Subproject commit eebea94dfce251d24ad6e342cf8026bd18540eac