service: break up
Break up the monster AuxioService into sub-classes, keeping just the major lifecycle and music stuff in AuxioService for now (which will likely be split out itself eventually)
This commit is contained in:
parent
5b8518a567
commit
99a527983b
16 changed files with 2238 additions and 1925 deletions
|
@ -82,7 +82,7 @@
|
|||
Service handling music playback, system components, and state saving.
|
||||
-->
|
||||
<service
|
||||
android:name=".service.AuxioService"
|
||||
android:name=".AuxioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
|
|
577
app/src/main/java/org/oxycblt/auxio/AuxioService.kt
Normal file
577
app/src/main/java/org/oxycblt/auxio/AuxioService.kt
Normal file
|
@ -0,0 +1,577 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* AuxioService.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.ContentObserver
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSession.ConnectionResult
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import coil.ImageLoader
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.guava.asListenableFuture
|
||||
import org.oxycblt.auxio.image.service.NeoBitmapLoader
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.service.IndexingNotification
|
||||
import org.oxycblt.auxio.music.service.MusicMediaItemBrowser
|
||||
import org.oxycblt.auxio.music.service.ObservingNotification
|
||||
import org.oxycblt.auxio.playback.ActionMode
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.service.ExoPlaybackStateHolder
|
||||
import org.oxycblt.auxio.playback.service.SystemPlaybackReceiver
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
// TODO: Android Auto Hookup
|
||||
// TODO: Custom notif
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService :
|
||||
MediaLibraryService(),
|
||||
MediaLibrarySession.Callback,
|
||||
MusicRepository.IndexingWorker,
|
||||
MusicRepository.IndexingListener,
|
||||
MusicRepository.UpdateListener,
|
||||
MusicSettings.Listener,
|
||||
PlaybackStateManager.Listener,
|
||||
PlaybackSettings.Listener {
|
||||
private val serviceJob = Job()
|
||||
private var inPlayback = false
|
||||
|
||||
@Inject lateinit var musicRepository: MusicRepository
|
||||
@Inject lateinit var musicSettings: MusicSettings
|
||||
private lateinit var indexingNotification: IndexingNotification
|
||||
private lateinit var observingNotification: ObservingNotification
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var indexerContentObserver: SystemContentObserver
|
||||
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
private var currentIndexJob: Job? = null
|
||||
|
||||
@Inject lateinit var playbackManager: PlaybackStateManager
|
||||
@Inject lateinit var playbackSettings: PlaybackSettings
|
||||
@Inject lateinit var systemReceiver: SystemPlaybackReceiver
|
||||
@Inject lateinit var exoHolderFactory: ExoPlaybackStateHolder.Factory
|
||||
private lateinit var exoHolder: ExoPlaybackStateHolder
|
||||
|
||||
@Inject lateinit var bitmapLoader: NeoBitmapLoader
|
||||
@Inject lateinit var imageLoader: ImageLoader
|
||||
|
||||
@Inject lateinit var musicMediaItemBrowser: MusicMediaItemBrowser
|
||||
private val waitScope = CoroutineScope(serviceJob + Dispatchers.Default)
|
||||
private lateinit var mediaSession: MediaLibrarySession
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
indexingNotification = IndexingNotification(this)
|
||||
observingNotification = ObservingNotification(this)
|
||||
wakeLock =
|
||||
getSystemServiceCompat(PowerManager::class)
|
||||
.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
|
||||
|
||||
exoHolder = exoHolderFactory.create()
|
||||
|
||||
mediaSession =
|
||||
MediaLibrarySession.Builder(this, exoHolder.mediaSessionPlayer, this)
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.build()
|
||||
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
indexerContentObserver = SystemContentObserver()
|
||||
|
||||
setMediaNotificationProvider(
|
||||
DefaultMediaNotificationProvider.Builder(this)
|
||||
.setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE)
|
||||
.setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK")
|
||||
.setChannelName(R.string.lbl_playback)
|
||||
.build()
|
||||
.also { it.setSmallIcon(R.drawable.ic_auxio_24) })
|
||||
addSession(mediaSession)
|
||||
updateCustomButtons()
|
||||
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
exoHolder.attach()
|
||||
playbackManager.addListener(this)
|
||||
playbackSettings.registerListener(this)
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
this, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
|
||||
|
||||
musicMediaItemBrowser.attach()
|
||||
musicSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
musicRepository.registerWorker(this)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
if (!playbackManager.progression.isPlaying) {
|
||||
// Stop the service if not playing, continue playing in the background
|
||||
// otherwise.
|
||||
endSession()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// De-initialize core service components first.
|
||||
serviceJob.cancel()
|
||||
wakeLock.releaseSafe()
|
||||
// Then cancel the listener-dependent components to ensure that stray reloading
|
||||
// events will not occur.
|
||||
indexerContentObserver.release()
|
||||
exoHolder.release()
|
||||
musicSettings.unregisterListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
musicRepository.removeIndexingListener(this)
|
||||
musicRepository.unregisterWorker(this)
|
||||
|
||||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.playing(false)
|
||||
playbackManager.unregisterStateHolder(exoHolder)
|
||||
playbackSettings.unregisterListener(this)
|
||||
|
||||
removeSession(mediaSession)
|
||||
mediaSession.release()
|
||||
unregisterReceiver(systemReceiver)
|
||||
exoHolder.release()
|
||||
}
|
||||
|
||||
// --- INDEXER OVERRIDES ---
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
|
||||
// Cancel the previous music loading job.
|
||||
currentIndexJob?.cancel()
|
||||
// Start a new music loading job on a co-routine.
|
||||
currentIndexJob = musicRepository.index(this, withCache)
|
||||
}
|
||||
|
||||
override val workerContext: Context
|
||||
get() = this
|
||||
|
||||
override val scope = indexScope
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
updateForeground(forMusic = true)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
logD("Music changed, updating shared objects")
|
||||
// Wipe possibly-invalidated outdated covers
|
||||
imageLoader.memoryCache?.clear()
|
||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||
// to a listener as it is bad practice for a shared object to attach to
|
||||
// the listener system of another.
|
||||
playbackManager.toSavedState()?.let { savedState ->
|
||||
playbackManager.applySavedState(
|
||||
savedState.copy(
|
||||
heap =
|
||||
savedState.heap.map { song ->
|
||||
song?.let { deviceLibrary.findSong(it.uid) }
|
||||
}),
|
||||
true)
|
||||
}
|
||||
}
|
||||
|
||||
// --- INTERNAL ---
|
||||
|
||||
private fun updateForeground(forMusic: Boolean) {
|
||||
if (playbackManager.progression.isPlaying) {
|
||||
inPlayback = true
|
||||
}
|
||||
|
||||
if (inPlayback) {
|
||||
if (!forMusic) {
|
||||
val notification =
|
||||
mediaNotificationProvider.createNotification(
|
||||
mediaSession,
|
||||
mediaSession.customLayout,
|
||||
mediaNotificationManager.actionFactory) { notification ->
|
||||
postMediaNotification(notification, mediaSession)
|
||||
}
|
||||
postMediaNotification(notification, mediaSession)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
updateLoadingForeground(state.progress)
|
||||
} else {
|
||||
updateIdleForeground()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLoadingForeground(progress: IndexingProgress) {
|
||||
// When loading, we want to enter the foreground state so that android does
|
||||
// not shut off the loading process. Note that while we will always post the
|
||||
// notification when initially starting, we will not update the notification
|
||||
// unless it indicates that it has changed.
|
||||
val changed = indexingNotification.updateIndexingState(progress)
|
||||
if (changed) {
|
||||
logD("Notification changed, re-posting notification")
|
||||
startForeground(indexingNotification.code, indexingNotification.build())
|
||||
}
|
||||
// Make sure we can keep the CPU on while loading music
|
||||
wakeLock.acquireSafe()
|
||||
}
|
||||
|
||||
private fun updateIdleForeground() {
|
||||
if (musicSettings.shouldBeObserving) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
// 1. Newer versions of Android have become more and more restrictive regarding
|
||||
// how a foreground service starts. Thus, it's best to go foreground now so that
|
||||
// we can go foreground later.
|
||||
// 2. If a non-foreground service is killed, the app will probably still be alive,
|
||||
// and thus the music library will not be updated at all.
|
||||
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
|
||||
// this anymore, or at least I only have to use it when the app task is not removed.
|
||||
logD("Need to observe, staying in foreground")
|
||||
startForeground(observingNotification.code, observingNotification.build())
|
||||
} else {
|
||||
// Not observing and done loading, exit foreground.
|
||||
logD("Exiting foreground")
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
|
||||
}
|
||||
// Release our wake lock (if we were using it)
|
||||
wakeLock.releaseSafe()
|
||||
}
|
||||
|
||||
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.acquireSafe() {
|
||||
// Avoid unnecessary acquire calls.
|
||||
if (!wakeLock.isHeld) {
|
||||
logD("Acquiring wake lock")
|
||||
// Time out after a minute, which is the average music loading time for a medium-sized
|
||||
// library. If this runs out, we will re-request the lock, and if music loading is
|
||||
// shorter than the timeout, it will be released early.
|
||||
acquire(WAKELOCK_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.releaseSafe() {
|
||||
// Avoid unnecessary release calls.
|
||||
if (wakeLock.isHeld) {
|
||||
logD("Releasing wake lock")
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// --- SETTING CALLBACKS ---
|
||||
|
||||
override fun onIndexingSettingChanged() {
|
||||
// Music loading configuration changed, need to reload music.
|
||||
requestIndex(true)
|
||||
}
|
||||
|
||||
override fun onObservingChanged() {
|
||||
// Make sure we don't override the service state with the observing
|
||||
// notification if we were actively loading when the automatic rescanning
|
||||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (currentIndexJob == null) {
|
||||
logD("Not loading, updating idle session")
|
||||
updateForeground(forMusic = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
|
||||
* known to the user as automatic rescanning. The active (and not passive) nature of observing
|
||||
* the database is what requires [IndexerService] to stay foreground when this is enabled.
|
||||
*/
|
||||
private inner class SystemContentObserver :
|
||||
ContentObserver(Handler(Looper.getMainLooper())), Runnable {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
contentResolverSafe.registerContentObserver(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, preventing it from further observing the database and cancelling
|
||||
* any pending update events.
|
||||
*/
|
||||
fun release() {
|
||||
handler.removeCallbacks(this)
|
||||
contentResolverSafe.unregisterContentObserver(this)
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
// Batch rapid-fire updates to the library into a single call to run after 500ms
|
||||
handler.removeCallbacks(this)
|
||||
handler.postDelayed(this, REINDEX_DELAY_MS)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
// Check here if we should even start a reindex. This is much less bug-prone than
|
||||
// registering and de-registering this component as this setting changes.
|
||||
if (musicSettings.shouldBeObserving) {
|
||||
logD("MediaStore changed, starting re-index")
|
||||
requestIndex(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SERVICE MANAGEMENT ---
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
||||
mediaSession
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
logD("Notification update requested")
|
||||
updateForeground(forMusic = false)
|
||||
}
|
||||
|
||||
private fun postMediaNotification(notification: MediaNotification, session: MediaSession) {
|
||||
// Pulled from MediaNotificationManager: Need to specify MediaSession token manually
|
||||
// in notification
|
||||
val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token
|
||||
notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
|
||||
startForeground(notification.notificationId, notification.notification)
|
||||
}
|
||||
|
||||
// --- MEDIASESSION CALLBACKS ---
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): ConnectionResult {
|
||||
val sessionCommands =
|
||||
ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
|
||||
.add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
|
||||
.add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
|
||||
.add(SessionCommand(ACTION_EXIT, Bundle.EMPTY))
|
||||
.build()
|
||||
return ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(sessionCommands)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> =
|
||||
when (customCommand.customAction) {
|
||||
ACTION_INC_REPEAT_MODE -> {
|
||||
logD(playbackManager.repeatMode.increment())
|
||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
ACTION_INVERT_SHUFFLE -> {
|
||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
ACTION_EXIT -> {
|
||||
endSession()
|
||||
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
else -> super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> =
|
||||
Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params))
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val result =
|
||||
musicMediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
|
||||
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
return Futures.immediateFuture(result)
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val children =
|
||||
musicMediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
|
||||
LibraryResult.ofItemList(it, params)
|
||||
}
|
||||
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
return Futures.immediateFuture(children)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<Void>> =
|
||||
waitScope
|
||||
.async {
|
||||
musicMediaItemBrowser.prepareSearch(query)
|
||||
LibraryResult.ofVoid()
|
||||
}
|
||||
.asListenableFuture()
|
||||
|
||||
override fun onGetSearchResult(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: LibraryParams?
|
||||
) =
|
||||
waitScope
|
||||
.async {
|
||||
musicMediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
|
||||
LibraryResult.ofItemList(it, params)
|
||||
}
|
||||
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
}
|
||||
.asListenableFuture()
|
||||
|
||||
// --- BUTTON MANAGEMENT ---
|
||||
|
||||
override fun onPauseOnRepeatChanged() {
|
||||
super.onPauseOnRepeatChanged()
|
||||
updateCustomButtons()
|
||||
}
|
||||
|
||||
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
|
||||
super.onQueueReordered(queue, index, isShuffled)
|
||||
updateCustomButtons()
|
||||
}
|
||||
|
||||
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
|
||||
super.onRepeatModeChanged(repeatMode)
|
||||
updateCustomButtons()
|
||||
}
|
||||
|
||||
override fun onNotificationActionChanged() {
|
||||
super.onNotificationActionChanged()
|
||||
updateCustomButtons()
|
||||
}
|
||||
|
||||
private fun updateCustomButtons() {
|
||||
val actions = mutableListOf<CommandButton>()
|
||||
|
||||
when (playbackSettings.notificationAction) {
|
||||
ActionMode.REPEAT -> {
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(playbackManager.repeatMode.icon)
|
||||
.setDisplayName(getString(R.string.desc_change_repeat))
|
||||
.setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle()))
|
||||
.build())
|
||||
}
|
||||
ActionMode.SHUFFLE -> {
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(
|
||||
if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24
|
||||
else R.drawable.ic_shuffle_off_24)
|
||||
.setDisplayName(getString(R.string.lbl_shuffle))
|
||||
.setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle()))
|
||||
.build())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(R.drawable.ic_close_24)
|
||||
.setDisplayName(getString(R.string.desc_exit))
|
||||
.setSessionCommand(SessionCommand(ACTION_EXIT, Bundle()))
|
||||
.build())
|
||||
|
||||
mediaSession.setCustomLayout(actions)
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
// This session has ended, so we need to reset this flag for when the next
|
||||
// session starts.
|
||||
exoHolder.save {
|
||||
// User could feasibly start playing again if they were fast enough, so
|
||||
// we need to avoid stopping the foreground state if that's the case.
|
||||
if (playbackManager.progression.isPlaying) {
|
||||
playbackManager.playing(false)
|
||||
}
|
||||
inPlayback = false
|
||||
updateForeground(forMusic = false)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
||||
const val REINDEX_DELAY_MS = 500L
|
||||
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
|
||||
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
|
||||
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
|
||||
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
|
||||
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
|
||||
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
|
||||
}
|
||||
}
|
|
@ -31,7 +31,6 @@ import javax.inject.Inject
|
|||
import org.oxycblt.auxio.databinding.ActivityMainBinding
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.service.AuxioService
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.isNight
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* CoilBitmapLoader.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.util.BitmapLoader
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.image.BitmapProvider
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.service.MediaSessionUID
|
||||
|
||||
class NeoBitmapLoader
|
||||
@Inject
|
||||
constructor(
|
||||
private val musicRepository: MusicRepository,
|
||||
private val bitmapProvider: BitmapProvider
|
||||
) : BitmapLoader {
|
||||
override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture<Bitmap> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap>? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
||||
val future = SettableFuture.create<Bitmap>()
|
||||
val song =
|
||||
when (val uid =
|
||||
metadata.extras?.getString("uid")?.let { MediaSessionUID.fromString(it) }) {
|
||||
is MediaSessionUID.Single -> deviceLibrary.findSong(uid.uid)
|
||||
is MediaSessionUID.Joined -> deviceLibrary.findSong(uid.childUid)
|
||||
else -> return null
|
||||
}
|
||||
?: return null
|
||||
bitmapProvider.load(
|
||||
song,
|
||||
object : BitmapProvider.Target {
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
if (bitmap == null) {
|
||||
future.setException(IllegalStateException("Bitmap is null"))
|
||||
} else {
|
||||
future.set(bitmap)
|
||||
}
|
||||
}
|
||||
})
|
||||
return future
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.service
|
||||
package org.oxycblt.auxio.music.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
|
@ -25,6 +25,7 @@ import org.oxycblt.auxio.BuildConfig
|
|||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.ui.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MediaItemTranslation.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
|
||||
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(context.getString(nameRes))
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(mediaType)
|
||||
.build()
|
||||
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build()
|
||||
}
|
||||
|
||||
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
||||
val mediaSessionUID =
|
||||
if (parent == null) {
|
||||
MediaSessionUID.Single(uid)
|
||||
} else {
|
||||
MediaSessionUID.Joined(parent.uid, uid)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(album.name.resolve(context))
|
||||
.setAlbumArtist(album.artists.resolveNames(context))
|
||||
.setTrackNumber(track)
|
||||
.setDiscNumber(disc?.number)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setDisplayTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setRecordingYear(album.dates?.min?.year)
|
||||
.setRecordingMonth(album.dates?.min?.month)
|
||||
.setRecordingDay(album.dates?.min?.day)
|
||||
.setReleaseYear(album.dates?.min?.year)
|
||||
.setReleaseMonth(album.dates?.min?.month)
|
||||
.setReleaseDay(album.dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setArtworkUri(album.coverUri.mediaStore)
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString("uid", mediaSessionUID.toString())
|
||||
putLong("durationMs", durationMs)
|
||||
})
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem {
|
||||
val mediaSessionUID =
|
||||
if (parent == null) {
|
||||
MediaSessionUID.Single(uid)
|
||||
} else {
|
||||
MediaSessionUID.Joined(parent.uid, uid)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(name.resolve(context))
|
||||
.setAlbumArtist(artists.resolveNames(context))
|
||||
.setRecordingYear(dates?.min?.year)
|
||||
.setRecordingMonth(dates?.min?.month)
|
||||
.setRecordingDay(dates?.min?.day)
|
||||
.setReleaseYear(dates?.min?.year)
|
||||
.setReleaseMonth(dates?.min?.month)
|
||||
.setReleaseDay(dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(coverUri.mediaStore)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem {
|
||||
val mediaSessionUID =
|
||||
if (parent == null) {
|
||||
MediaSessionUID.Single(uid)
|
||||
} else {
|
||||
MediaSessionUID.Joined(parent.uid, uid)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
if (explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}))
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Genre.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Playlist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
|
||||
val uid = MediaSessionUID.fromString(mediaId) ?: return null
|
||||
return when (uid) {
|
||||
is MediaSessionUID.Single -> {
|
||||
deviceLibrary.findSong(uid.uid)
|
||||
}
|
||||
is MediaSessionUID.Joined -> {
|
||||
deviceLibrary.findSong(uid.childUid)
|
||||
}
|
||||
is MediaSessionUID.Category -> null
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface MediaSessionUID {
|
||||
enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) :
|
||||
MediaSessionUID {
|
||||
ROOT("root", R.string.info_app_name, null),
|
||||
SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC),
|
||||
ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
||||
GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
|
||||
PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
||||
|
||||
override fun toString() = "$ID_CATEGORY:$id"
|
||||
}
|
||||
|
||||
data class Single(val uid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$uid"
|
||||
}
|
||||
|
||||
data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$parentUid>$childUid"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
|
||||
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
|
||||
|
||||
fun fromString(str: String): MediaSessionUID? {
|
||||
val parts = str.split(":", limit = 2)
|
||||
if (parts.size != 2) {
|
||||
return null
|
||||
}
|
||||
return when (parts[0]) {
|
||||
ID_CATEGORY ->
|
||||
when (parts[1]) {
|
||||
Category.ROOT.id -> Category.ROOT
|
||||
Category.SONGS.id -> Category.SONGS
|
||||
Category.ALBUMS.id -> Category.ALBUMS
|
||||
Category.ARTISTS.id -> Category.ARTISTS
|
||||
Category.GENRES.id -> Category.GENRES
|
||||
Category.PLAYLISTS.id -> Category.PLAYLISTS
|
||||
else -> null
|
||||
}
|
||||
ID_ITEM -> {
|
||||
val uids = parts[1].split(">", limit = 2)
|
||||
if (uids.size == 1) {
|
||||
Music.UID.fromString(uids[0])?.let { Single(it) }
|
||||
} else {
|
||||
Music.UID.fromString(uids[0])?.let { parent ->
|
||||
Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,245 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MusicMediaItemBrowser.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.user.UserLibrary
|
||||
import org.oxycblt.auxio.search.SearchEngine
|
||||
|
||||
class MusicMediaItemBrowser
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val searchEngine: SearchEngine
|
||||
) : MusicRepository.UpdateListener {
|
||||
private val browserJob = Job()
|
||||
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
|
||||
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
|
||||
|
||||
fun attach() {
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.deviceLibrary) {
|
||||
for (entry in searchResults.entries) {
|
||||
entry.value.cancel()
|
||||
}
|
||||
searchResults.clear()
|
||||
}
|
||||
}
|
||||
|
||||
val root: MediaItem
|
||||
get() = MediaSessionUID.Category.ROOT.toMediaItem(context)
|
||||
|
||||
fun getItem(mediaId: String): MediaItem? {
|
||||
val music =
|
||||
when (val uid = MediaSessionUID.fromString(mediaId)) {
|
||||
is MediaSessionUID.Category -> return uid.toMediaItem(context)
|
||||
is MediaSessionUID.Single ->
|
||||
musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) }
|
||||
is MediaSessionUID.Joined ->
|
||||
musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) }
|
||||
null -> null
|
||||
}
|
||||
?: return null
|
||||
|
||||
return when (music) {
|
||||
is Album -> music.toMediaItem(context, null)
|
||||
is Artist -> music.toMediaItem(context, null)
|
||||
is Genre -> music.toMediaItem(context)
|
||||
is Playlist -> music.toMediaItem(context)
|
||||
is Song -> music.toMediaItem(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun getChildren(parentId: String, page: Int, pageSize: Int): List<MediaItem>? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (deviceLibrary == null || userLibrary == null) {
|
||||
return listOf()
|
||||
}
|
||||
|
||||
val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null
|
||||
return items.paginate(page, pageSize)
|
||||
}
|
||||
|
||||
private fun getMediaItemList(
|
||||
id: String,
|
||||
deviceLibrary: DeviceLibrary,
|
||||
userLibrary: UserLibrary
|
||||
): List<MediaItem>? {
|
||||
return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
|
||||
is MediaSessionUID.Category -> {
|
||||
when (mediaSessionUID) {
|
||||
MediaSessionUID.Category.ROOT ->
|
||||
listOf(
|
||||
MediaSessionUID.Category.SONGS,
|
||||
MediaSessionUID.Category.ALBUMS,
|
||||
MediaSessionUID.Category.ARTISTS,
|
||||
MediaSessionUID.Category.GENRES,
|
||||
MediaSessionUID.Category.PLAYLISTS)
|
||||
.map { it.toMediaItem(context) }
|
||||
MediaSessionUID.Category.SONGS ->
|
||||
deviceLibrary.songs.map { it.toMediaItem(context, null) }
|
||||
MediaSessionUID.Category.ALBUMS ->
|
||||
deviceLibrary.albums.map { it.toMediaItem(context, null) }
|
||||
MediaSessionUID.Category.ARTISTS ->
|
||||
deviceLibrary.artists.map { it.toMediaItem(context, null) }
|
||||
MediaSessionUID.Category.GENRES ->
|
||||
deviceLibrary.genres.map { it.toMediaItem(context) }
|
||||
MediaSessionUID.Category.PLAYLISTS ->
|
||||
userLibrary.playlists.map { it.toMediaItem(context) }
|
||||
}
|
||||
}
|
||||
is MediaSessionUID.Single -> {
|
||||
getChildMediaItems(mediaSessionUID.uid) ?: return null
|
||||
}
|
||||
is MediaSessionUID.Joined -> {
|
||||
getChildMediaItems(mediaSessionUID.childUid) ?: return null
|
||||
}
|
||||
null -> return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? {
|
||||
return when (val item = musicRepository.find(uid)) {
|
||||
is Album -> {
|
||||
item.songs.map { it.toMediaItem(context, item) }
|
||||
}
|
||||
is Artist -> {
|
||||
(item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(context, item) } +
|
||||
item.songs.map { it.toMediaItem(context, item) }
|
||||
}
|
||||
is Genre -> {
|
||||
item.songs.map { it.toMediaItem(context, item) }
|
||||
}
|
||||
is Playlist -> {
|
||||
item.songs.map { it.toMediaItem(context, item) }
|
||||
}
|
||||
is Song,
|
||||
null -> return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prepareSearch(query: String) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (deviceLibrary == null || userLibrary == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (query.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
searchTo(query, deviceLibrary, userLibrary).await()
|
||||
}
|
||||
|
||||
suspend fun getSearchResult(
|
||||
query: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): List<MediaItem>? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (deviceLibrary == null || userLibrary == null) {
|
||||
return listOf()
|
||||
}
|
||||
|
||||
if (query.isEmpty()) {
|
||||
return listOf()
|
||||
}
|
||||
|
||||
val existing = searchResults[query]
|
||||
if (existing != null) {
|
||||
return existing.await().concat().paginate(page, pageSize)
|
||||
}
|
||||
|
||||
return searchTo(query, deviceLibrary, userLibrary).await().concat().paginate(page, pageSize)
|
||||
}
|
||||
|
||||
private fun SearchEngine.Items.concat(): MutableList<MediaItem> {
|
||||
val music = mutableListOf<MediaItem>()
|
||||
if (songs != null) {
|
||||
music.addAll(songs.map { it.toMediaItem(context, null) })
|
||||
}
|
||||
if (albums != null) {
|
||||
music.addAll(albums.map { it.toMediaItem(context, null) })
|
||||
}
|
||||
if (artists != null) {
|
||||
music.addAll(artists.map { it.toMediaItem(context, null) })
|
||||
}
|
||||
if (genres != null) {
|
||||
music.addAll(genres.map { it.toMediaItem(context) })
|
||||
}
|
||||
if (playlists != null) {
|
||||
music.addAll(playlists.map { it.toMediaItem(context) })
|
||||
}
|
||||
return music
|
||||
}
|
||||
|
||||
private fun searchTo(query: String, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) =
|
||||
searchScope.async {
|
||||
val items =
|
||||
SearchEngine.Items(
|
||||
deviceLibrary.songs,
|
||||
deviceLibrary.albums,
|
||||
deviceLibrary.artists,
|
||||
deviceLibrary.genres,
|
||||
userLibrary.playlists)
|
||||
searchEngine.search(items, query)
|
||||
}
|
||||
|
||||
private fun List<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? {
|
||||
if (page == Int.MAX_VALUE) {
|
||||
// I think if someone requests this page it more or less implies that I should
|
||||
// return all of the pages.
|
||||
return this
|
||||
}
|
||||
val start = page * pageSize
|
||||
val end = (page + 1) * pageSize
|
||||
if (pageSize == 0 || start !in indices || end - 1 !in indices) {
|
||||
// These pages are probably invalid. Hopefully this won't backfire.
|
||||
return null
|
||||
}
|
||||
return subList(page * pageSize, (page + 1) * pageSize).toMutableList()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,551 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* ExoPlaybackStateHolder.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.audiofx.AudioEffect
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.service.toMediaItem
|
||||
import org.oxycblt.auxio.music.service.toSong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.playback.state.PlaybackCommand
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateHolder
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.Progression
|
||||
import org.oxycblt.auxio.playback.state.RawQueue
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.state.ShuffleMode
|
||||
import org.oxycblt.auxio.playback.state.StateAck
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
class ExoPlaybackStateHolder(
|
||||
private val context: Context,
|
||||
private val player: ExoPlayer,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val persistenceRepository: PersistenceRepository,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val musicRepository: MusicRepository
|
||||
) :
|
||||
PlaybackStateHolder,
|
||||
Player.Listener,
|
||||
MusicRepository.UpdateListener,
|
||||
PlaybackSettings.Listener {
|
||||
private val saveJob = Job()
|
||||
private val saveScope = CoroutineScope(Dispatchers.IO + saveJob)
|
||||
private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob)
|
||||
private var currentSaveJob: Job? = null
|
||||
private var openAudioEffectSession = false
|
||||
|
||||
fun attach() {
|
||||
player.addListener(this)
|
||||
playbackManager.registerStateHolder(this)
|
||||
playbackSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
saveJob.cancel()
|
||||
player.removeListener(this)
|
||||
playbackManager.unregisterStateHolder(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
player.release()
|
||||
}
|
||||
|
||||
override var parent: MusicParent? = null
|
||||
private set
|
||||
|
||||
val mediaSessionPlayer: Player
|
||||
get() = MediaSessionPlayer(player, playbackManager, commandFactory, musicRepository)
|
||||
|
||||
override val progression: Progression
|
||||
get() {
|
||||
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
||||
val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE
|
||||
val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration)
|
||||
return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition)
|
||||
}
|
||||
|
||||
override val repeatMode
|
||||
get() =
|
||||
when (val repeatMode = player.repeatMode) {
|
||||
Player.REPEAT_MODE_OFF -> RepeatMode.NONE
|
||||
Player.REPEAT_MODE_ONE -> RepeatMode.TRACK
|
||||
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
|
||||
else -> throw IllegalStateException("Unknown repeat mode: $repeatMode")
|
||||
}
|
||||
|
||||
override val audioSessionId: Int
|
||||
get() = player.audioSessionId
|
||||
|
||||
override fun resolveQueue(): RawQueue {
|
||||
val deviceLibrary =
|
||||
musicRepository.deviceLibrary
|
||||
// No library, cannot do anything.
|
||||
?: return RawQueue(emptyList(), emptyList(), 0)
|
||||
val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) }
|
||||
val shuffledMapping =
|
||||
if (player.shuffleModeEnabled) {
|
||||
player.unscrambleQueueIndices()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return RawQueue(
|
||||
heap.mapNotNull { it.toSong(deviceLibrary) },
|
||||
shuffledMapping,
|
||||
player.currentMediaItemIndex)
|
||||
}
|
||||
|
||||
override fun handleDeferred(action: DeferredPlayback): Boolean {
|
||||
val deviceLibrary =
|
||||
musicRepository.deviceLibrary
|
||||
// No library, cannot do anything.
|
||||
?: return false
|
||||
|
||||
when (action) {
|
||||
// Restore state -> Start a new restoreState job
|
||||
is DeferredPlayback.RestoreState -> {
|
||||
logD("Restoring playback state")
|
||||
restoreScope.launch {
|
||||
persistenceRepository.readState()?.let {
|
||||
// Apply the saved state on the main thread to prevent code expecting
|
||||
// state updates on the main thread from crashing.
|
||||
withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shuffle all -> Start new playback from all songs
|
||||
is DeferredPlayback.ShuffleAll -> {
|
||||
logD("Shuffling all tracks")
|
||||
playbackManager.play(
|
||||
requireNotNull(commandFactory.all(ShuffleMode.ON)) {
|
||||
"Invalid playback parameters"
|
||||
})
|
||||
}
|
||||
// Open -> Try to find the Song for the given file and then play it from all songs
|
||||
is DeferredPlayback.Open -> {
|
||||
logD("Opening specified file")
|
||||
deviceLibrary.findSongForUri(context, action.uri)?.let { song ->
|
||||
playbackManager.play(
|
||||
requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) {
|
||||
"Invalid playback parameters"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun playing(playing: Boolean) {
|
||||
player.playWhenReady = playing
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
player.seekTo(positionMs)
|
||||
// Ack/state save handled on discontinuity
|
||||
}
|
||||
|
||||
override fun repeatMode(repeatMode: RepeatMode) {
|
||||
player.repeatMode =
|
||||
when (repeatMode) {
|
||||
RepeatMode.NONE -> Player.REPEAT_MODE_OFF
|
||||
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
|
||||
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
||||
}
|
||||
updatePauseOnRepeat()
|
||||
playbackManager.ack(this, StateAck.RepeatModeChanged)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun newPlayback(command: PlaybackCommand) {
|
||||
parent = command.parent
|
||||
player.shuffleModeEnabled = command.shuffled
|
||||
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
|
||||
val startIndex =
|
||||
command.song
|
||||
?.let { command.queue.indexOf(it) }
|
||||
.also { check(it != -1) { "Start song not in queue" } }
|
||||
if (command.shuffled) {
|
||||
player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1))
|
||||
}
|
||||
val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled)
|
||||
player.seekTo(target, C.TIME_UNSET)
|
||||
player.prepare()
|
||||
player.play()
|
||||
playbackManager.ack(this, StateAck.NewPlayback)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun shuffled(shuffled: Boolean) {
|
||||
player.setShuffleModeEnabled(shuffled)
|
||||
if (player.shuffleModeEnabled) {
|
||||
// Have to manually refresh the shuffle seed and anchor it to the new current songs
|
||||
player.setShuffleOrder(
|
||||
BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex))
|
||||
}
|
||||
playbackManager.ack(this, StateAck.QueueReordered)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun next() {
|
||||
// Replicate the old pseudo-circular queue behavior when no repeat option is implemented.
|
||||
// Basically, you can't skip back and wrap around the queue, but you can skip forward and
|
||||
// wrap around the queue, albeit playback will be paused.
|
||||
if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) {
|
||||
player.seekToNext()
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
player.seekTo(
|
||||
player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET)
|
||||
// TODO: Dislike the UX implications of this, I feel should I bite the bullet
|
||||
// and switch to dynamic skip enable/disable?
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
// Ack/state save is handled in timeline change
|
||||
}
|
||||
|
||||
override fun prev() {
|
||||
if (playbackSettings.rewindWithPrev) {
|
||||
player.seekToPrevious()
|
||||
} else {
|
||||
player.seekToPreviousMediaItem()
|
||||
}
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
// Ack/state save is handled in timeline change
|
||||
}
|
||||
|
||||
override fun goto(index: Int) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueIndex = indices[index]
|
||||
player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
// Ack/state save is handled in timeline change
|
||||
}
|
||||
|
||||
override fun playNext(songs: List<Song>, ack: StateAck.PlayNext) {
|
||||
val currTimeline = player.currentTimeline
|
||||
val nextIndex =
|
||||
if (currTimeline.isEmpty) {
|
||||
C.INDEX_UNSET
|
||||
} else {
|
||||
currTimeline.getNextWindowIndex(
|
||||
player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
|
||||
}
|
||||
|
||||
if (nextIndex == C.INDEX_UNSET) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
} else {
|
||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun move(from: Int, to: Int, ack: StateAck.Move) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueFrom = indices[from]
|
||||
val trueTo = indices[to]
|
||||
when {
|
||||
trueFrom > trueTo -> {
|
||||
player.moveMediaItem(trueFrom, trueTo)
|
||||
}
|
||||
trueTo > trueFrom -> {
|
||||
player.moveMediaItem(trueFrom, trueTo)
|
||||
}
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun remove(at: Int, ack: StateAck.Remove) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueIndex = indices[at]
|
||||
val songWillChange = player.currentMediaItemIndex == trueIndex
|
||||
player.removeMediaItem(trueIndex)
|
||||
if (songWillChange && !playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun applySavedState(
|
||||
parent: MusicParent?,
|
||||
rawQueue: RawQueue,
|
||||
ack: StateAck.NewPlayback?
|
||||
) {
|
||||
this.parent = parent
|
||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
||||
if (rawQueue.isShuffled) {
|
||||
player.shuffleModeEnabled = true
|
||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||
} else {
|
||||
player.shuffleModeEnabled = false
|
||||
}
|
||||
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
|
||||
player.prepare()
|
||||
ack?.let { playbackManager.ack(this, it) }
|
||||
}
|
||||
|
||||
override fun reset(ack: StateAck.NewPlayback) {
|
||||
player.setMediaItems(listOf())
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
// --- PLAYER OVERRIDES ---
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
super.onPlayWhenReadyChanged(playWhenReady, reason)
|
||||
|
||||
if (player.playWhenReady) {
|
||||
// Mark that we have started playing so that the notification can now be posted.
|
||||
logD("Player has started playing")
|
||||
if (!openAudioEffectSession) {
|
||||
// Convention to start an audioeffect session on play/pause rather than
|
||||
// start/stop
|
||||
logD("Opening audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = true
|
||||
}
|
||||
} else if (openAudioEffectSession) {
|
||||
// Make sure to close the audio session when we stop playback.
|
||||
logD("Closing audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
super.onMediaItemTransition(mediaItem, reason)
|
||||
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
|
||||
reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) {
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) {
|
||||
goto(0)
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
||||
// TODO: Once position also naturally drifts by some threshold, save
|
||||
deferSave()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
|
||||
if (events.containsAny(
|
||||
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||
Player.EVENT_IS_PLAYING_CHANGED,
|
||||
Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
logD("Player state changed, must synchronize state")
|
||||
playbackManager.ack(this, StateAck.ProgressionChanged)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
// TODO: Replace with no skipping and a notification instead
|
||||
// If there's any issue, just go to the next song.
|
||||
logE("Player error occurred")
|
||||
logE(error.stackTraceToString())
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
private fun broadcastAudioEffectAction(event: String) {
|
||||
logD("Broadcasting AudioEffect event: $event")
|
||||
context.sendBroadcast(
|
||||
Intent(event)
|
||||
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
|
||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
|
||||
}
|
||||
|
||||
// --- MUSICREPOSITORY METHODS ---
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
|
||||
// We now have a library, see if we have anything we need to do.
|
||||
logD("Library obtained, requesting action")
|
||||
playbackManager.requestAction(this)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PLAYBACKSETTINGS OVERRIDES ---
|
||||
|
||||
override fun onPauseOnRepeatChanged() {
|
||||
super.onPauseOnRepeatChanged()
|
||||
updatePauseOnRepeat()
|
||||
}
|
||||
|
||||
private fun updatePauseOnRepeat() {
|
||||
player.pauseAtEndOfMediaItems =
|
||||
player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat
|
||||
}
|
||||
|
||||
fun save(cb: () -> Unit) {
|
||||
saveJob {
|
||||
persistenceRepository.saveState(playbackManager.toSavedState())
|
||||
withContext(Dispatchers.Main) { cb() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deferSave() {
|
||||
saveJob {
|
||||
logD("Waiting for save buffer")
|
||||
delay(SAVE_BUFFER)
|
||||
yield()
|
||||
logD("Committing saved state")
|
||||
persistenceRepository.saveState(playbackManager.toSavedState())
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveJob(block: suspend () -> Unit) {
|
||||
currentSaveJob?.let {
|
||||
logD("Discarding prior save job")
|
||||
it.cancel()
|
||||
}
|
||||
currentSaveJob = saveScope.launch { block() }
|
||||
}
|
||||
|
||||
class Factory
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val persistenceRepository: PersistenceRepository,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val mediaSourceFactory: MediaSource.Factory,
|
||||
private val replayGainProcessor: ReplayGainAudioProcessor
|
||||
) {
|
||||
fun create(): ExoPlaybackStateHolder {
|
||||
// Since Auxio is a music player, only specify an audio renderer to save
|
||||
// battery/apk size/cache size
|
||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||
arrayOf(
|
||||
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
|
||||
MediaCodecAudioRenderer(
|
||||
context,
|
||||
MediaCodecSelector.DEFAULT,
|
||||
handler,
|
||||
audioListener,
|
||||
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
|
||||
replayGainProcessor))
|
||||
}
|
||||
|
||||
val exoPlayer =
|
||||
ExoPlayer.Builder(context, audioRenderer)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
// Enable automatic WakeLock support
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setAudioAttributes(
|
||||
// Signal that we are a music player.
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
true)
|
||||
.build()
|
||||
|
||||
return ExoPlaybackStateHolder(
|
||||
context,
|
||||
exoPlayer,
|
||||
playbackManager,
|
||||
persistenceRepository,
|
||||
playbackSettings,
|
||||
commandFactory,
|
||||
musicRepository)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAVE_BUFFER = 5000L
|
||||
}
|
||||
}
|
|
@ -25,8 +25,8 @@ import android.content.Intent
|
|||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.AuxioService
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.service.AuxioService
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,380 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MediaSessionPlayer.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.service.MediaSessionUID
|
||||
import org.oxycblt.auxio.music.service.toSong
|
||||
import org.oxycblt.auxio.playback.state.PlaybackCommand
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.state.ShuffleMode
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A thin wrapper around the player instance that takes all the events I know MediaSession will send
|
||||
* and routes them to PlaybackStateManager so I know that they will work the way I want it to.
|
||||
* @author Alexander Capehart
|
||||
*/
|
||||
class MediaSessionPlayer(
|
||||
player: Player,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val musicRepository: MusicRepository
|
||||
) : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
return super.getAvailableCommands()
|
||||
.buildUpon()
|
||||
.addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean {
|
||||
// We can always skip forward and backward (this is to retain parity with the old behavior)
|
||||
return super.isCommandAvailable(command) ||
|
||||
command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
}
|
||||
|
||||
override fun setMediaItems(
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long
|
||||
) {
|
||||
// We assume the only people calling this method are going to be the MediaSession callbacks,
|
||||
// since anything else (like newPlayback) will be calling directly on the player. As part
|
||||
// of this, we expand the given MediaItems into the command that should be sent to the
|
||||
// player.
|
||||
val command =
|
||||
if (mediaItems.size > 1) {
|
||||
this.playMediaItemSelection(mediaItems, startIndex)
|
||||
} else {
|
||||
this.playSingleMediaItem(mediaItems.first())
|
||||
}
|
||||
requireNotNull(command) { "Invalid playback configuration" }
|
||||
playbackManager.play(command)
|
||||
if (startPositionMs != C.TIME_UNSET) {
|
||||
playbackManager.seekTo(startPositionMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun playMediaItemSelection(
|
||||
mediaItems: List<MediaItem>,
|
||||
startIndex: Int
|
||||
): PlaybackCommand? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
||||
val targetSong = mediaItems.getOrNull(startIndex)?.toSong(deviceLibrary)
|
||||
val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) }
|
||||
var index = startIndex
|
||||
if (targetSong != null) {
|
||||
while (songs.getOrNull(index)?.uid != targetSong.uid) {
|
||||
index--
|
||||
}
|
||||
}
|
||||
return commandFactory.songs(songs, ShuffleMode.OFF)
|
||||
}
|
||||
|
||||
private fun playSingleMediaItem(mediaItem: MediaItem): PlaybackCommand? {
|
||||
val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null
|
||||
val music: Music
|
||||
var parent: MusicParent? = null
|
||||
when (uid) {
|
||||
is MediaSessionUID.Single -> {
|
||||
music = musicRepository.find(uid.uid) ?: return null
|
||||
}
|
||||
is MediaSessionUID.Joined -> {
|
||||
music = musicRepository.find(uid.childUid) ?: return null
|
||||
parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return when (music) {
|
||||
is Song -> inferSongFromParentCommand(music, parent)
|
||||
is Album -> commandFactory.album(music, ShuffleMode.OFF)
|
||||
is Artist -> commandFactory.artist(music, ShuffleMode.OFF)
|
||||
is Genre -> commandFactory.genre(music, ShuffleMode.OFF)
|
||||
is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF)
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) =
|
||||
when (parent) {
|
||||
is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT)
|
||||
is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT)
|
||||
?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT)
|
||||
is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT)
|
||||
?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT)
|
||||
is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT)
|
||||
null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT)
|
||||
}
|
||||
|
||||
override fun setPlayWhenReady(playWhenReady: Boolean) {
|
||||
playbackManager.playing(playWhenReady)
|
||||
}
|
||||
|
||||
override fun setRepeatMode(repeatMode: Int) {
|
||||
val appRepeatMode =
|
||||
when (repeatMode) {
|
||||
Player.REPEAT_MODE_OFF -> RepeatMode.NONE
|
||||
Player.REPEAT_MODE_ONE -> RepeatMode.TRACK
|
||||
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
|
||||
else -> throw IllegalStateException("Unknown repeat mode: $repeatMode")
|
||||
}
|
||||
playbackManager.repeatMode(appRepeatMode)
|
||||
}
|
||||
|
||||
override fun seekToNext() = playbackManager.next()
|
||||
|
||||
override fun seekToNextMediaItem() = playbackManager.next()
|
||||
|
||||
override fun seekToPrevious() = playbackManager.prev()
|
||||
|
||||
override fun seekToPreviousMediaItem() = playbackManager.prev()
|
||||
|
||||
override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs)
|
||||
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||
val indices = unscrambleQueueIndices()
|
||||
val fakeIndex = indices.indexOf(mediaItemIndex)
|
||||
if (fakeIndex < 0) {
|
||||
return
|
||||
}
|
||||
playbackManager.goto(fakeIndex)
|
||||
if (positionMs == C.TIME_UNSET) {
|
||||
return
|
||||
}
|
||||
playbackManager.seekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) }
|
||||
when {
|
||||
index ==
|
||||
currentTimeline.getNextWindowIndex(
|
||||
currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> {
|
||||
playbackManager.playNext(songs)
|
||||
}
|
||||
index >= mediaItemCount -> playbackManager.addToQueue(songs)
|
||||
else -> error("Unsupported index $index")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
|
||||
playbackManager.shuffled(shuffleModeEnabled)
|
||||
}
|
||||
|
||||
override fun moveMediaItem(currentIndex: Int, newIndex: Int) {
|
||||
val indices = unscrambleQueueIndices()
|
||||
val fakeFrom = indices.indexOf(currentIndex)
|
||||
if (fakeFrom < 0) {
|
||||
return
|
||||
}
|
||||
val fakeTo =
|
||||
if (newIndex >= mediaItemCount) {
|
||||
currentTimeline.getLastWindowIndex(shuffleModeEnabled)
|
||||
} else {
|
||||
indices.indexOf(newIndex)
|
||||
}
|
||||
if (fakeTo < 0) {
|
||||
return
|
||||
}
|
||||
playbackManager.moveQueueItem(fakeFrom, fakeTo)
|
||||
}
|
||||
|
||||
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) =
|
||||
error("Multi-item queue moves are unsupported")
|
||||
|
||||
override fun removeMediaItem(index: Int) {
|
||||
val indices = unscrambleQueueIndices()
|
||||
val fakeAt = indices.indexOf(index)
|
||||
if (fakeAt < 0) {
|
||||
return
|
||||
}
|
||||
playbackManager.removeQueueItem(fakeAt)
|
||||
}
|
||||
|
||||
override fun removeMediaItems(fromIndex: Int, toIndex: Int) =
|
||||
error("Any multi-item queue removal is unsupported")
|
||||
|
||||
// These methods I don't want MediaSession calling in any way since they'll do insane things
|
||||
// that I'm not tracking. If they do call them, I will know.
|
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed()
|
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed()
|
||||
|
||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>) = notAllowed()
|
||||
|
||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) =
|
||||
notAllowed()
|
||||
|
||||
override fun addMediaItem(mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun addMediaItems(mediaItems: MutableList<MediaItem>) = notAllowed()
|
||||
|
||||
override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun replaceMediaItems(
|
||||
fromIndex: Int,
|
||||
toIndex: Int,
|
||||
mediaItems: MutableList<MediaItem>
|
||||
) = notAllowed()
|
||||
|
||||
override fun clearMediaItems() = notAllowed()
|
||||
|
||||
override fun setPlaybackSpeed(speed: Float) = notAllowed()
|
||||
|
||||
override fun seekToDefaultPosition() = notAllowed()
|
||||
|
||||
override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed()
|
||||
|
||||
override fun seekForward() = notAllowed()
|
||||
|
||||
override fun seekBack() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun next() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun previous() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed()
|
||||
|
||||
override fun play() = playbackManager.playing(true)
|
||||
|
||||
override fun pause() = playbackManager.playing(false)
|
||||
|
||||
override fun prepare() = notAllowed()
|
||||
|
||||
override fun release() = notAllowed()
|
||||
|
||||
override fun stop() = notAllowed()
|
||||
|
||||
override fun hasNextMediaItem() = notAllowed()
|
||||
|
||||
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) =
|
||||
notAllowed()
|
||||
|
||||
override fun setVolume(volume: Float) = notAllowed()
|
||||
|
||||
override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed()
|
||||
|
||||
override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed()
|
||||
|
||||
override fun increaseDeviceVolume(flags: Int) = notAllowed()
|
||||
|
||||
override fun decreaseDeviceVolume(flags: Int) = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed()
|
||||
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed()
|
||||
|
||||
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed()
|
||||
|
||||
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed()
|
||||
|
||||
override fun setVideoSurface(surface: Surface?) = notAllowed()
|
||||
|
||||
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed()
|
||||
|
||||
override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed()
|
||||
|
||||
override fun setVideoTextureView(textureView: TextureView?) = notAllowed()
|
||||
|
||||
override fun clearVideoSurface() = notAllowed()
|
||||
|
||||
override fun clearVideoSurface(surface: Surface?) = notAllowed()
|
||||
|
||||
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed()
|
||||
|
||||
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed()
|
||||
|
||||
override fun clearVideoTextureView(textureView: TextureView?) = notAllowed()
|
||||
|
||||
private fun notAllowed(): Nothing = error("MediaSession unexpectedly called this method")
|
||||
}
|
||||
|
||||
fun Player.unscrambleQueueIndices(): List<Int> {
|
||||
val timeline = currentTimeline
|
||||
if (timeline.isEmpty) {
|
||||
return emptyList()
|
||||
}
|
||||
val queue = mutableListOf<Int>()
|
||||
|
||||
// Add the active queue item.
|
||||
val currentMediaItemIndex = currentMediaItemIndex
|
||||
queue.add(currentMediaItemIndex)
|
||||
|
||||
// Fill queue alternating with next and/or previous queue items.
|
||||
var firstMediaItemIndex = currentMediaItemIndex
|
||||
var lastMediaItemIndex = currentMediaItemIndex
|
||||
val shuffleModeEnabled = shuffleModeEnabled
|
||||
while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) {
|
||||
// Begin with next to have a longer tail than head if an even sized queue needs to be
|
||||
// trimmed.
|
||||
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||
lastMediaItemIndex =
|
||||
timeline.getNextWindowIndex(
|
||||
lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||
queue.add(lastMediaItemIndex)
|
||||
}
|
||||
}
|
||||
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||
firstMediaItemIndex =
|
||||
timeline.getPreviousWindowIndex(
|
||||
firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||
queue.add(0, firstMediaItemIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queue
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* SystemPlaybackReciever.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.AuxioService
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
|
||||
* active [IntentFilter] to be registered.
|
||||
*/
|
||||
class SystemPlaybackReceiver
|
||||
@Inject
|
||||
constructor(
|
||||
val playbackManager: PlaybackStateManager,
|
||||
val playbackSettings: PlaybackSettings,
|
||||
val widgetComponent: WidgetComponent
|
||||
) : BroadcastReceiver() {
|
||||
private var initialHeadsetPlugEventHandled = false
|
||||
|
||||
val intentFilter =
|
||||
IntentFilter().apply {
|
||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||
addAction(AuxioService.ACTION_INC_REPEAT_MODE)
|
||||
addAction(AuxioService.ACTION_INVERT_SHUFFLE)
|
||||
addAction(AuxioService.ACTION_SKIP_PREV)
|
||||
addAction(AuxioService.ACTION_PLAY_PAUSE)
|
||||
addAction(AuxioService.ACTION_SKIP_NEXT)
|
||||
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
// --- SYSTEM EVENTS ---
|
||||
|
||||
// Android has three different ways of handling audio plug events for some reason:
|
||||
// 1. ACTION_HEADSET_PLUG, which only works with wired headsets
|
||||
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
|
||||
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
|
||||
// a non-starter since both require me to display a permission prompt
|
||||
// 3. Some internal framework thing that also handles bluetooth headsets
|
||||
// Just use ACTION_HEADSET_PLUG.
|
||||
AudioManager.ACTION_HEADSET_PLUG -> {
|
||||
logD("Received headset plug event")
|
||||
when (intent.getIntExtra("state", -1)) {
|
||||
0 -> pauseFromHeadsetPlug()
|
||||
1 -> playFromHeadsetPlug()
|
||||
}
|
||||
|
||||
initialHeadsetPlugEventHandled = true
|
||||
}
|
||||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
|
||||
logD("Received Headset noise event")
|
||||
pauseFromHeadsetPlug()
|
||||
}
|
||||
|
||||
// --- AUXIO EVENTS ---
|
||||
AuxioService.ACTION_PLAY_PAUSE -> {
|
||||
logD("Received play event")
|
||||
playbackManager.playing(!playbackManager.progression.isPlaying)
|
||||
}
|
||||
AuxioService.ACTION_INC_REPEAT_MODE -> {
|
||||
logD("Received repeat mode event")
|
||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||
}
|
||||
AuxioService.ACTION_INVERT_SHUFFLE -> {
|
||||
logD("Received shuffle event")
|
||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||
}
|
||||
AuxioService.ACTION_SKIP_PREV -> {
|
||||
logD("Received skip previous event")
|
||||
playbackManager.prev()
|
||||
}
|
||||
AuxioService.ACTION_SKIP_NEXT -> {
|
||||
logD("Received skip next event")
|
||||
playbackManager.next()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> {
|
||||
logD("Received widget update event")
|
||||
widgetComponent.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playFromHeadsetPlug() {
|
||||
// ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached,
|
||||
// which would result in unexpected playback. Work around it by dropping the first
|
||||
// call to this function, which should come from that Intent.
|
||||
if (playbackSettings.headsetAutoplay &&
|
||||
playbackManager.currentSong != null &&
|
||||
initialHeadsetPlugEventHandled) {
|
||||
logD("Device connected, resuming")
|
||||
playbackManager.playing(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseFromHeadsetPlug() {
|
||||
if (playbackManager.currentSong != null) {
|
||||
logD("Device disconnected, pausing")
|
||||
playbackManager.playing(false)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -51,9 +51,7 @@ interface PlaybackStateHolder {
|
|||
/** The current audio session ID of the audio player. */
|
||||
val audioSessionId: Int
|
||||
|
||||
/**
|
||||
* Applies a completely new playback state to the holder.
|
||||
*/
|
||||
/** Applies a completely new playback state to the holder. */
|
||||
fun newPlayback(command: PlaybackCommand)
|
||||
|
||||
/**
|
||||
|
|
|
@ -492,7 +492,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
}
|
||||
}
|
||||
|
||||
private class QueueCommand(override val queue: List<Song>) : PlaybackCommand {
|
||||
private class QueueCommand(override val queue: List<Song>) : PlaybackCommand {
|
||||
override val song: Song? = null
|
||||
override val parent: MusicParent? = null
|
||||
override val shuffled = false
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.service
|
||||
package org.oxycblt.auxio.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
|
@ -28,11 +28,11 @@ import android.os.Bundle
|
|||
import android.util.SizeF
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import org.oxycblt.auxio.AuxioService
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.service.AuxioService
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
|
Loading…
Reference in a new issue