playback: rearchitecture around media3 (prototype)
Nowhere near complete in any capacity.
This commit is contained in:
parent
d27d99be53
commit
04ea6834fb
15 changed files with 1500 additions and 1747 deletions
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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(
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
2
media
|
@ -1 +1 @@
|
|||
Subproject commit e585deaa94cc679ab4fd0a653cc1bf67abb54b7e
|
||||
Subproject commit eebea94dfce251d24ad6e342cf8026bd18540eac
|
Loading…
Reference in a new issue