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 handling music playback, system components, and state saving.
|
||||||
-->
|
-->
|
||||||
<service
|
<service
|
||||||
android:name=".service.AuxioService"
|
android:name=".AuxioService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:exported="true"
|
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.databinding.ActivityMainBinding
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||||
import org.oxycblt.auxio.service.AuxioService
|
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.isNight
|
import org.oxycblt.auxio.util.isNight
|
||||||
import org.oxycblt.auxio.util.logD
|
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/>.
|
* 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.content.Context
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
|
@ -25,6 +25,7 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.IndexingProgress
|
import org.oxycblt.auxio.music.IndexingProgress
|
||||||
|
import org.oxycblt.auxio.ui.ForegroundServiceNotification
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
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 androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import org.oxycblt.auxio.AuxioService
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.service.AuxioService
|
|
||||||
import org.oxycblt.auxio.util.logD
|
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. */
|
/** The current audio session ID of the audio player. */
|
||||||
val audioSessionId: Int
|
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)
|
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 song: Song? = null
|
||||||
override val parent: MusicParent? = null
|
override val parent: MusicParent? = null
|
||||||
override val shuffled = false
|
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/>.
|
* 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 android.content.Context
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
|
@ -28,11 +28,11 @@ import android.os.Bundle
|
||||||
import android.util.SizeF
|
import android.util.SizeF
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
|
import org.oxycblt.auxio.AuxioService
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.service.AuxioService
|
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
Loading…
Reference in a new issue