service: drop media3 session entirely

This commit is contained in:
Alexander Capehart 2024-08-26 10:45:58 -06:00
parent c1e5adbc44
commit e43f55bc78
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 1308 additions and 862 deletions

View file

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

View file

@ -19,34 +19,35 @@
package org.oxycblt.auxio
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import android.support.v4.media.MediaBrowserCompat
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media.MediaBrowserServiceCompat
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.music.service.IndexerServiceFragment
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
@AndroidEntryPoint
class AuxioService : MediaLibraryService(), ForegroundListener {
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
@Inject lateinit var mediaSessionFragment: PlaybackServiceFragment
@Inject lateinit var indexingFragment: IndexerServiceFragment
@Inject lateinit var indexingFragment: MusicServiceFragment
@SuppressLint("WrongConstant")
override fun onCreate() {
super.onCreate()
mediaSessionFragment.attach(this, this)
setSessionToken(mediaSessionFragment.attach(this))
indexingFragment.attach(this)
}
override fun onBind(intent: Intent?): IBinder? {
onHandleForeground(intent)
return super.onBind(intent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here.
@ -54,6 +55,11 @@ class AuxioService : MediaLibraryService(), ForegroundListener {
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent): IBinder? {
onHandleForeground(intent)
return super.onBind(intent)
}
private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
indexingFragment.start()
@ -71,20 +77,54 @@ class AuxioService : MediaLibraryService(), ForegroundListener {
mediaSessionFragment.release()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
mediaSessionFragment.mediaSession
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
TODO("Not yet implemented")
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
) = throw NotImplementedError()
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaBrowserCompat.MediaItem>>,
options: Bundle
) {
super.onLoadChildren(parentId, result, options)
}
override fun onLoadItem(itemId: String, result: Result<MediaBrowserCompat.MediaItem>) {
super.onLoadItem(itemId, result)
}
override fun onSearch(
query: String,
extras: Bundle?,
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
) {
super.onSearch(query, extras, result)
}
@SuppressLint("RestrictedApi")
override fun onSubscribe(id: String?, option: Bundle?) {
super.onSubscribe(id, option)
}
@SuppressLint("RestrictedApi")
override fun onUnsubscribe(id: String?) {
super.onUnsubscribe(id)
}
override fun updateForeground(change: ForegroundListener.Change) {
if (mediaSessionFragment.hasNotification()) {
val mediaNotification = mediaSessionFragment.notification
if (mediaNotification != null) {
if (change == ForegroundListener.Change.MEDIA_SESSION) {
mediaSessionFragment.createNotification {
startForeground(it.notificationId, it.notification)
isForeground = true
}
startForeground(mediaNotification.code, mediaNotification.build())
}
// Nothing changed, but don't show anything music related since we can always
// index during playback.
@ -118,3 +158,42 @@ interface ForegroundListener {
INDEXER
}
}
/**
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* signal a Service's ongoing foreground state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
.setLightsEnabled(false)
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
*
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
/**
* Reduced representation of a [NotificationChannelCompat].
*
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
}

View file

@ -79,7 +79,7 @@ class MainActivity : AppCompatActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
startIntentAction(intent)
}

View file

@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.SystemClock
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.IndexingProgress
@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* signal a Service's ongoing foreground state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class IndexerNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
.setLightsEnabled(false)
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
*
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
/**
* Reduced representation of a [NotificationChannelCompat].
*
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
}
/**
* A dynamic [IndexerNotification] that shows the current music loading state.
* A dynamic [ForegroundServiceNotification] that shows the current music loading state.
*
* @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexingNotification(private val context: Context) :
IndexerNotification(context, indexerChannel) {
ForegroundServiceNotification(context, indexerChannel) {
private var lastUpdateTime = -1L
init {
@ -133,12 +92,13 @@ class IndexingNotification(private val context: Context) :
}
/**
* A static [IndexerNotification] that signals to the user that the app is currently monitoring the
* music library for changes.
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
* monitoring the music library for changes.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
class ObservingNotification(context: Context) :
ForegroundServiceNotification(context, indexerChannel) {
init {
setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel =
IndexerNotification.ChannelInfo(
ForegroundServiceNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

@ -17,358 +17,358 @@
*/
package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.Bundle
import androidx.annotation.StringRes
import androidx.media.utils.MediaConstants
import androidx.media3.common.MediaItem
import androidx.media3.session.MediaSession.ControllerInfo
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlin.math.min
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort
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 MediaItemBrowser
@Inject
constructor(
@ApplicationContext private val context: Context,
private val musicRepository: MusicRepository,
private val listSettings: ListSettings,
private val searchEngine: SearchEngine
) : MusicRepository.UpdateListener {
private val browserJob = Job()
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
private val searchSubscribers = mutableMapOf<ControllerInfo, String>()
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
private var invalidator: Invalidator? = null
interface Invalidator {
fun invalidate(ids: Map<String, Int>)
fun invalidate(controller: ControllerInfo, query: String, itemCount: Int)
}
fun attach(invalidator: Invalidator) {
this.invalidator = invalidator
musicRepository.addUpdateListener(this)
}
fun release() {
browserJob.cancel()
invalidator = null
musicRepository.removeUpdateListener(this)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary
var invalidateSearch = false
val invalidate = mutableMapOf<String, Int>()
if (changes.deviceLibrary && deviceLibrary != null) {
MediaSessionUID.Category.DEVICE_MUSIC.forEach {
invalidate[it.toString()] = getCategorySize(it, musicRepository)
}
deviceLibrary.albums.forEach {
val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size
}
deviceLibrary.artists.forEach {
val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size
}
deviceLibrary.genres.forEach {
val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size + it.artists.size
}
invalidateSearch = true
}
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
MediaSessionUID.Category.USER_MUSIC.forEach {
invalidate[it.toString()] = getCategorySize(it, musicRepository)
}
userLibrary.playlists.forEach {
val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size
}
invalidateSearch = true
}
if (invalidate.isNotEmpty()) {
invalidator?.invalidate(invalidate)
}
if (invalidateSearch) {
for (entry in searchResults.entries) {
searchResults[entry.key]?.cancel()
}
searchResults.clear()
for (entry in searchSubscribers.entries) {
if (searchResults[entry.value] != null) {
continue
}
searchResults[entry.value] = searchTo(entry.value)
}
}
}
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)
is Artist -> music.toMediaItem(context)
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 ->
MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) }
MediaSessionUID.Category.SONGS ->
listSettings.songSort.songs(deviceLibrary.songs).map {
it.toMediaItem(context, null)
}
MediaSessionUID.Category.ALBUMS ->
listSettings.albumSort.albums(deviceLibrary.albums).map {
it.toMediaItem(context)
}
MediaSessionUID.Category.ARTISTS ->
listSettings.artistSort.artists(deviceLibrary.artists).map {
it.toMediaItem(context)
}
MediaSessionUID.Category.GENRES ->
listSettings.genreSort.genres(deviceLibrary.genres).map {
it.toMediaItem(context)
}
MediaSessionUID.Category.PLAYLISTS ->
userLibrary.playlists.map { it.toMediaItem(context) }
}
}
is MediaSessionUID.Single -> {
getChildMediaItems(mediaSessionUID.uid)
}
is MediaSessionUID.Joined -> {
getChildMediaItems(mediaSessionUID.childUid)
}
null -> {
return null
}
}
}
private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? {
return when (val item = musicRepository.find(uid)) {
is Album -> {
val songs = listSettings.albumSongSort.songs(item.songs)
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
}
is Artist -> {
val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
val songs = listSettings.artistSongSort.songs(item.songs)
albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } +
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
}
is Genre -> {
val artists = GENRE_ARTISTS_SORT.artists(item.artists)
val songs = listSettings.genreSongSort.songs(item.songs)
artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } +
songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) }
}
is Playlist -> {
item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
}
is Song,
null -> return null
}
}
private fun MediaItem.withHeader(@StringRes res: Int): MediaItem {
val oldExtras = mediaMetadata.extras ?: Bundle()
val newExtras =
Bundle(oldExtras).apply {
putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
context.getString(res))
}
return buildUpon()
.setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build())
.build()
}
private fun getCategorySize(
category: MediaSessionUID.Category,
musicRepository: MusicRepository
): Int {
val deviceLibrary = musicRepository.deviceLibrary ?: return 0
val userLibrary = musicRepository.userLibrary ?: return 0
return when (category) {
MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size
MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size
MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size
MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size
MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size
MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size
}
}
suspend fun prepareSearch(query: String, controller: ControllerInfo) {
searchSubscribers[controller] = query
val existing = searchResults[query]
if (existing == null) {
val new = searchTo(query)
searchResults[query] = new
new.await()
} else {
val items = existing.await()
invalidator?.invalidate(controller, query, items.count())
}
}
suspend fun getSearchResult(
query: String,
page: Int,
pageSize: Int,
): List<MediaItem>? {
val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it }
return deferred.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) })
}
if (artists != null) {
music.addAll(artists.map { it.toMediaItem(context) })
}
if (genres != null) {
music.addAll(genres.map { it.toMediaItem(context) })
}
if (playlists != null) {
music.addAll(playlists.map { it.toMediaItem(context) })
}
return music
}
private fun SearchEngine.Items.count(): Int {
var count = 0
if (songs != null) {
count += songs.size
}
if (albums != null) {
count += albums.size
}
if (artists != null) {
count += artists.size
}
if (genres != null) {
count += genres.size
}
if (playlists != null) {
count += playlists.size
}
return count
}
private fun searchTo(query: String) =
searchScope.async {
if (query.isEmpty()) {
return@async SearchEngine.Items()
}
val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items()
val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items()
val items =
SearchEngine.Items(
deviceLibrary.songs,
deviceLibrary.albums,
deviceLibrary.artists,
deviceLibrary.genres,
userLibrary.playlists)
val results = searchEngine.search(items, query)
for (entry in searchSubscribers.entries) {
if (entry.value == query) {
invalidator?.invalidate(entry.key, query, results.count())
}
}
results
}
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 = min((page + 1) * pageSize, size) // Tolerate partial page queries
if (pageSize == 0 || start !in indices) {
// These pages are probably invalid. Hopefully this won't backfire.
return null
}
return subList(start, end).toMutableList()
}
private companion object {
// TODO: Rely on detail item gen logic?
val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
}
}
//
// import android.content.Context
// import android.os.Bundle
// import androidx.annotation.StringRes
// import androidx.media.utils.MediaConstants
// import androidx.media3.common.MediaItem
// import androidx.media3.session.MediaSession.ControllerInfo
// import dagger.hilt.android.qualifiers.ApplicationContext
// import javax.inject.Inject
// import kotlin.math.min
// import kotlinx.coroutines.CoroutineScope
// import kotlinx.coroutines.Deferred
// import kotlinx.coroutines.Dispatchers
// import kotlinx.coroutines.Job
// import kotlinx.coroutines.async
// import org.oxycblt.auxio.R
// import org.oxycblt.auxio.list.ListSettings
// import org.oxycblt.auxio.list.sort.Sort
// 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 MediaItemBrowser
// @Inject
// constructor(
// @ApplicationContext private val context: Context,
// private val musicRepository: MusicRepository,
// private val listSettings: ListSettings,
// private val searchEngine: SearchEngine
// ) : MusicRepository.UpdateListener {
// private val browserJob = Job()
// private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
// private val searchSubscribers = mutableMapOf<ControllerInfo, String>()
// private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
// private var invalidator: Invalidator? = null
//
// interface Invalidator {
// fun invalidate(ids: Map<String, Int>)
//
// fun invalidate(controller: ControllerInfo, query: String, itemCount: Int)
// }
//
// fun attach(invalidator: Invalidator) {
// this.invalidator = invalidator
// musicRepository.addUpdateListener(this)
// }
//
// fun release() {
// browserJob.cancel()
// invalidator = null
// musicRepository.removeUpdateListener(this)
// }
//
// override fun onMusicChanges(changes: MusicRepository.Changes) {
// val deviceLibrary = musicRepository.deviceLibrary
// var invalidateSearch = false
// val invalidate = mutableMapOf<String, Int>()
// if (changes.deviceLibrary && deviceLibrary != null) {
// MediaSessionUID.Category.DEVICE_MUSIC.forEach {
// invalidate[it.toString()] = getCategorySize(it, musicRepository)
// }
//
// deviceLibrary.albums.forEach {
// val id = MediaSessionUID.Single(it.uid).toString()
// invalidate[id] = it.songs.size
// }
//
// deviceLibrary.artists.forEach {
// val id = MediaSessionUID.Single(it.uid).toString()
// invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size
// }
//
// deviceLibrary.genres.forEach {
// val id = MediaSessionUID.Single(it.uid).toString()
// invalidate[id] = it.songs.size + it.artists.size
// }
//
// invalidateSearch = true
// }
// val userLibrary = musicRepository.userLibrary
// if (changes.userLibrary && userLibrary != null) {
// MediaSessionUID.Category.USER_MUSIC.forEach {
// invalidate[it.toString()] = getCategorySize(it, musicRepository)
// }
// userLibrary.playlists.forEach {
// val id = MediaSessionUID.Single(it.uid).toString()
// invalidate[id] = it.songs.size
// }
// invalidateSearch = true
// }
//
// if (invalidate.isNotEmpty()) {
// invalidator?.invalidate(invalidate)
// }
//
// if (invalidateSearch) {
// for (entry in searchResults.entries) {
// searchResults[entry.key]?.cancel()
// }
// searchResults.clear()
//
// for (entry in searchSubscribers.entries) {
// if (searchResults[entry.value] != null) {
// continue
// }
// searchResults[entry.value] = searchTo(entry.value)
// }
// }
// }
//
// 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)
// is Artist -> music.toMediaItem(context)
// 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 ->
// MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) }
// MediaSessionUID.Category.SONGS ->
// listSettings.songSort.songs(deviceLibrary.songs).map {
// it.toMediaItem(context, null)
// }
// MediaSessionUID.Category.ALBUMS ->
// listSettings.albumSort.albums(deviceLibrary.albums).map {
// it.toMediaItem(context)
// }
// MediaSessionUID.Category.ARTISTS ->
// listSettings.artistSort.artists(deviceLibrary.artists).map {
// it.toMediaItem(context)
// }
// MediaSessionUID.Category.GENRES ->
// listSettings.genreSort.genres(deviceLibrary.genres).map {
// it.toMediaItem(context)
// }
// MediaSessionUID.Category.PLAYLISTS ->
// userLibrary.playlists.map { it.toMediaItem(context) }
// }
// }
// is MediaSessionUID.Single -> {
// getChildMediaItems(mediaSessionUID.uid)
// }
// is MediaSessionUID.Joined -> {
// getChildMediaItems(mediaSessionUID.childUid)
// }
// null -> {
// return null
// }
// }
// }
//
// private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? {
// return when (val item = musicRepository.find(uid)) {
// is Album -> {
// val songs = listSettings.albumSongSort.songs(item.songs)
// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
// }
// is Artist -> {
// val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
// val songs = listSettings.artistSongSort.songs(item.songs)
// albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } +
// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
// }
// is Genre -> {
// val artists = GENRE_ARTISTS_SORT.artists(item.artists)
// val songs = listSettings.genreSongSort.songs(item.songs)
// artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } +
// songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) }
// }
// is Playlist -> {
// item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
// }
// is Song,
// null -> return null
// }
// }
//
// private fun MediaItem.withHeader(@StringRes res: Int): MediaItem {
// val oldExtras = mediaMetadata.extras ?: Bundle()
// val newExtras =
// Bundle(oldExtras).apply {
// putString(
// MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
// context.getString(res))
// }
// return buildUpon()
// .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build())
// .build()
// }
//
// private fun getCategorySize(
// category: MediaSessionUID.Category,
// musicRepository: MusicRepository
// ): Int {
// val deviceLibrary = musicRepository.deviceLibrary ?: return 0
// val userLibrary = musicRepository.userLibrary ?: return 0
// return when (category) {
// MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size
// MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size
// MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size
// MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size
// MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size
// MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size
// }
// }
//
// suspend fun prepareSearch(query: String, controller: ControllerInfo) {
// searchSubscribers[controller] = query
// val existing = searchResults[query]
// if (existing == null) {
// val new = searchTo(query)
// searchResults[query] = new
// new.await()
// } else {
// val items = existing.await()
// invalidator?.invalidate(controller, query, items.count())
// }
// }
//
// suspend fun getSearchResult(
// query: String,
// page: Int,
// pageSize: Int,
// ): List<MediaItem>? {
// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it }
// return deferred.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) })
// }
// if (artists != null) {
// music.addAll(artists.map { it.toMediaItem(context) })
// }
// if (genres != null) {
// music.addAll(genres.map { it.toMediaItem(context) })
// }
// if (playlists != null) {
// music.addAll(playlists.map { it.toMediaItem(context) })
// }
// return music
// }
//
// private fun SearchEngine.Items.count(): Int {
// var count = 0
// if (songs != null) {
// count += songs.size
// }
// if (albums != null) {
// count += albums.size
// }
// if (artists != null) {
// count += artists.size
// }
// if (genres != null) {
// count += genres.size
// }
// if (playlists != null) {
// count += playlists.size
// }
// return count
// }
//
// private fun searchTo(query: String) =
// searchScope.async {
// if (query.isEmpty()) {
// return@async SearchEngine.Items()
// }
// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items()
// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items()
// val items =
// SearchEngine.Items(
// deviceLibrary.songs,
// deviceLibrary.albums,
// deviceLibrary.artists,
// deviceLibrary.genres,
// userLibrary.playlists)
// val results = searchEngine.search(items, query)
// for (entry in searchSubscribers.entries) {
// if (entry.value == query) {
// invalidator?.invalidate(entry.key, query, results.count())
// }
// }
// results
// }
//
// 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 = min((page + 1) * pageSize, size) // Tolerate partial page queries
// if (pageSize == 0 || start !in indices) {
// // These pages are probably invalid. Hopefully this won't backfire.
// return null
// }
// return subList(start, end).toMutableList()
// }
//
// private companion object {
// // TODO: Rely on detail item gen logic?
// val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
// val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
// }
// }

View file

@ -28,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
@ -35,7 +36,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
class IndexerServiceFragment
class MusicServiceFragment
@Inject
constructor(
@ApplicationContext override val workerContext: Context,
@ -85,7 +86,7 @@ constructor(
}
}
fun createNotification(post: (IndexerNotification?) -> Unit) {
fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
// There are a few reasons why we stay in the foreground with automatic rescanning:

View file

@ -0,0 +1,609 @@
/*
* Copyright (c) 2021 Auxio Project
* MediaSessionHolder.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.system
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.service.PlaybackActions
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* A component that mirrors the current playback state into the [MediaSessionCompat] and
* [NotificationComponent].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MediaSessionHolder
private constructor(
private val context: Context,
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val bitmapProvider: BitmapProvider,
private val imageSettings: ImageSettings
) :
MediaSessionCompat.Callback(),
PlaybackStateManager.Listener,
ImageSettings.Listener,
PlaybackSettings.Listener {
class Factory
@Inject
constructor(
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val bitmapProvider: BitmapProvider,
private val imageSettings: ImageSettings
) {
fun create(context: Context) =
MediaSessionHolder(
context, playbackManager, playbackSettings, bitmapProvider, imageSettings)
}
private val mediaSession =
MediaSessionCompat(context, context.packageName).apply {
isActive = true
setQueueTitle(context.getString(R.string.lbl_queue))
}
val token: MediaSessionCompat.Token
get() = mediaSession.sessionToken
private val _notification = PlaybackNotification(context, mediaSession.sessionToken)
val notification: ForegroundServiceNotification
get() = _notification
private var foregroundListener: ForegroundListener? = null
fun attach(foregroundListener: ForegroundListener) {
this.foregroundListener = foregroundListener
playbackManager.addListener(this)
playbackSettings.registerListener(this)
imageSettings.registerListener(this)
mediaSession.setCallback(this)
}
/**
* Release this instance, closing the [MediaSessionCompat] and preventing any further updates to
* the [NotificationComponent].
*/
fun release() {
foregroundListener = null
bitmapProvider.release()
playbackSettings.unregisterListener(this)
imageSettings.unregisterListener(this)
playbackManager.removeListener(this)
mediaSession.apply {
isActive = false
release()
}
}
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
override fun onIndexMoved(index: Int) {
updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
invalidateSessionState()
}
override fun onQueueChanged(queue: List<Song>, index: Int, change: QueueChange) {
updateQueue(queue)
when (change.type) {
// Nothing special to do with mapping changes.
QueueChange.Type.MAPPING -> {}
// Index changed, ensure playback state's index changes.
QueueChange.Type.INDEX -> invalidateSessionState()
// Song changed, ensure metadata changes.
QueueChange.Type.SONG ->
updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
}
}
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
updateQueue(queue)
invalidateSessionState()
mediaSession.setShuffleMode(
if (isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
override fun onNewPlayback(
parent: MusicParent?,
queue: List<Song>,
index: Int,
isShuffled: Boolean
) {
updateMediaMetadata(playbackManager.currentSong, parent)
updateQueue(queue)
invalidateSessionState()
}
override fun onProgressionChanged(progression: Progression) {
invalidateSessionState()
_notification.updatePlaying(playbackManager.progression.isPlaying)
if (!bitmapProvider.isBusy) {
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
mediaSession.setRepeatMode(
when (repeatMode) {
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
})
invalidateSecondaryAction()
}
// --- SETTINGS OVERRIDES ---
override fun onImageSettingsChanged() {
// Need to reload the metadata cover.
updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
}
override fun onNotificationActionChanged() {
// Need to re-load the action shown in the notification.
invalidateSecondaryAction()
}
// --- MEDIASESSION OVERRIDES ---
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
super.onPlayFromMediaId(mediaId, extras)
// STUB: Unimplemented, no media browser
}
override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
super.onPlayFromUri(uri, extras)
// STUB: Unimplemented, no media browser
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
super.onPlayFromSearch(query, extras)
// STUB: Unimplemented, no media browser
}
override fun onAddQueueItem(description: MediaDescriptionCompat?) {
super.onAddQueueItem(description)
// STUB: Unimplemented
}
override fun onRemoveQueueItem(description: MediaDescriptionCompat?) {
super.onRemoveQueueItem(description)
// STUB: Unimplemented
}
override fun onPlay() {
playbackManager.playing(true)
}
override fun onPause() {
playbackManager.playing(false)
}
override fun onSkipToNext() {
playbackManager.next()
}
override fun onSkipToPrevious() {
playbackManager.prev()
}
override fun onSeekTo(position: Long) {
playbackManager.seekTo(position)
}
override fun onFastForward() {
playbackManager.next()
}
override fun onRewind() {
playbackManager.seekTo(0)
playbackManager.playing(true)
}
override fun onSetRepeatMode(repeatMode: Int) {
playbackManager.repeatMode(
when (repeatMode) {
PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL
PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL
PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK
else -> RepeatMode.NONE
})
}
override fun onSetShuffleMode(shuffleMode: Int) {
playbackManager.shuffled(
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP)
}
override fun onSkipToQueueItem(id: Long) {
playbackManager.goto(id.toInt())
}
override fun onCustomAction(action: String, extras: Bundle?) {
super.onCustomAction(action, extras)
// Service already handles intents from the old notification actions, easier to
// plug into that system.
context.sendBroadcast(Intent(action))
}
override fun onStop() {
// Get the service to shut down with the ACTION_EXIT intent
context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT))
}
// --- INTERNAL ---
/**
* Upload a new [MediaMetadataCompat] based on the current playback state to the
* [MediaSessionCompat] and [NotificationComponent].
*
* @param song The current [Song] to create the [MediaMetadataCompat] from, or null if no [Song]
* is currently playing.
* @param parent The current [MusicParent] to create the [MediaMetadataCompat] from, or null if
* playback is currently occuring from all songs.
*/
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
logD("Updating media metadata to $song with $parent")
if (song == null) {
// Nothing playing, reset the MediaSession and close the notification.
logD("Nothing playing, resetting media session")
mediaSession.setMetadata(emptyMetadata)
return
}
// Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
// several times.
val title = song.name.resolve(context)
val artist = song.artists.resolveNames(context)
val builder =
MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context))
// Note: We would leave the artist field null if it didn't exist and let downstream
// consumers handle it, but that would break the notification display.
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
song.album.artists.resolveNames(context))
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context))
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
parent?.run { name.resolve(context) }
?: context.getString(R.string.lbl_all_songs))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
// These fields are nullable and so we must check first before adding them to the fields.
song.track?.let {
logD("Adding track information")
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
}
song.disc?.let {
logD("Adding disc information")
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong())
}
song.date?.let {
logD("Adding date information")
builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString())
}
// We are normally supposed to use URIs for album art, but that removes some of the
// nice things we can do like square cropping or high quality covers. Instead,
// we load a full-size bitmap into the media session and take the performance hit.
bitmapProvider.load(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
logD("Bitmap loaded, applying media session and posting notification")
if (bitmap != null) {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
}
val metadata = builder.build()
mediaSession.setMetadata(metadata)
_notification.updateMetadata(metadata)
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
})
}
/**
* Upload a new queue to the [MediaSessionCompat].
*
* @param queue The current queue to upload.
*/
private fun updateQueue(queue: List<Song>) {
val queueItems =
queue.mapIndexed { i, song ->
val description =
MediaDescriptionCompat.Builder()
// Media ID should not be the item index but rather the UID,
// as it's used to request a song to be played from the queue.
.setMediaId(song.uid.toString())
.setTitle(song.name.resolve(context))
.setSubtitle(song.artists.resolveNames(context))
// Since we usually have to load many songs into the queue, use the
// MediaStore URI instead of loading a bitmap.
.setIconUri(song.album.cover.single.mediaStoreCoverUri)
.setMediaUri(song.uri)
.build()
// Store the item index so we can then use the analogous index in the
// playback state.
MediaSessionCompat.QueueItem(description, i.toLong())
}
logD("Uploading ${queueItems.size} songs to MediaSession queue")
mediaSession.setQueue(queueItems)
}
/** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */
private fun invalidateSessionState() {
logD("Updating media session playback state")
val state =
// InternalPlayer.State handles position/state information.
playbackManager.progression
.intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here.
.setActiveQueueItemId(playbackManager.index.toLong())
// Android 13+ relies on custom actions in the notification.
// Add the secondary action (either repeat/shuffle depending on the configuration)
val secondaryAction =
when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE -> {
logD("Using shuffle MediaSession action")
PlaybackStateCompat.CustomAction.Builder(
PlaybackActions.ACTION_INVERT_SHUFFLE,
context.getString(R.string.desc_shuffle),
if (playbackManager.isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
})
}
else -> {
logD("Using repeat mode MediaSession action")
PlaybackStateCompat.CustomAction.Builder(
PlaybackActions.ACTION_INC_REPEAT_MODE,
context.getString(R.string.desc_change_repeat),
playbackManager.repeatMode.icon)
}
}
state.addCustomAction(secondaryAction.build())
// Add the exit action so the service can be closed
val exitAction =
PlaybackStateCompat.CustomAction.Builder(
PlaybackActions.ACTION_EXIT,
context.getString(R.string.desc_exit),
R.drawable.ic_close_24)
.build()
state.addCustomAction(exitAction)
mediaSession.setPlaybackState(state.build())
}
/** Invalidate the "secondary" action (i.e shuffle/repeat mode). */
private fun invalidateSecondaryAction() {
logD("Invalidating secondary action")
invalidateSessionState()
when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE -> {
logD("Using shuffle notification action")
_notification.updateShuffled(playbackManager.isShuffled)
}
else -> {
logD("Using repeat mode notification action")
_notification.updateRepeatMode(playbackManager.repeatMode)
}
}
if (!bitmapProvider.isBusy) {
logD("Not loading a bitmap, post the notification")
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
}
companion object {
private val emptyMetadata = MediaMetadataCompat.Builder().build()
private const val ACTIONS =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SET_REPEAT_MODE or
PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or
PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_STOP
}
}
/**
* The playback notification component. Due to race conditions regarding notification updates, this
* component is not self-sufficient. [MediaSessionHolder] should be used instead of manage it.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@SuppressLint("RestrictedApi")
private class PlaybackNotification(
private val context: Context,
sessionToken: MediaSessionCompat.Token
) : ForegroundServiceNotification(context, CHANNEL_INFO) {
init {
setSmallIcon(R.drawable.ic_auxio_24)
setCategory(NotificationCompat.CATEGORY_TRANSPORT)
setShowWhen(false)
setSilent(true)
setContentIntent(context.newMainPendingIntent())
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
addAction(buildRepeatAction(context, RepeatMode.NONE))
addAction(
buildAction(context, PlaybackActions.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24))
addAction(buildPlayPauseAction(context, true))
addAction(
buildAction(context, PlaybackActions.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24))
addAction(buildAction(context, PlaybackActions.ACTION_EXIT, R.drawable.ic_close_24))
setStyle(
MediaStyle(this).setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3))
}
override val code: Int
get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE
// --- STATE FUNCTIONS ---
/**
* Update the currently shown metadata in this notification.
*
* @param metadata The [MediaMetadataCompat] to display in this notification.
*/
fun updateMetadata(metadata: MediaMetadataCompat) {
logD("Updating shown metadata")
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION))
}
/**
* Update the playing state shown in this notification.
*
* @param isPlaying Whether playback should be indicated as ongoing or paused.
*/
fun updatePlaying(isPlaying: Boolean) {
logD("Updating playing state: $isPlaying")
mActions[2] = buildPlayPauseAction(context, isPlaying)
}
/**
* Update the secondary action in this notification to show the current [RepeatMode].
*
* @param repeatMode The current [RepeatMode].
*/
fun updateRepeatMode(repeatMode: RepeatMode) {
logD("Applying repeat mode action: $repeatMode")
mActions[0] = buildRepeatAction(context, repeatMode)
}
/**
* Update the secondary action in this notification to show the current shuffle state.
*
* @param isShuffled Whether the queue is currently shuffled or not.
*/
fun updateShuffled(isShuffled: Boolean) {
logD("Applying shuffle action: $isShuffled")
mActions[0] = buildShuffleAction(context, isShuffled)
}
// --- NOTIFICATION ACTION BUILDERS ---
private fun buildPlayPauseAction(
context: Context,
isPlaying: Boolean
): NotificationCompat.Action {
val drawableRes =
if (isPlaying) {
R.drawable.ic_pause_24
} else {
R.drawable.ic_play_24
}
return buildAction(context, PlaybackActions.ACTION_PLAY_PAUSE, drawableRes)
}
private fun buildRepeatAction(
context: Context,
repeatMode: RepeatMode
): NotificationCompat.Action {
return buildAction(context, PlaybackActions.ACTION_INC_REPEAT_MODE, repeatMode.icon)
}
private fun buildShuffleAction(
context: Context,
isShuffled: Boolean
): NotificationCompat.Action {
val drawableRes =
if (isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
}
return buildAction(context, PlaybackActions.ACTION_INVERT_SHUFFLE, drawableRes)
}
private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) =
NotificationCompat.Action.Builder(
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
.build()
private companion object {
/** Notification channel used by solely the playback notification. */
val CHANNEL_INFO =
ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
nameRes = R.string.lbl_playback)
}
}

View file

@ -1,297 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* MediaSessionServiceFragment.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.app.Notification
import android.content.Context
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultActionFactory
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.MediaNotification.ActionFactory
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSession.ConnectionResult
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.qualifiers.ApplicationContext
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.BuildConfig
import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.service.MediaItemBrowser
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
import org.oxycblt.auxio.widgets.WidgetComponent
class MediaSessionServiceFragment
@Inject
constructor(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val actionHandler: PlaybackActionHandler,
private val playbackSettings: PlaybackSettings,
private val widgetComponent: WidgetComponent,
private val mediaItemBrowser: MediaItemBrowser,
exoHolderFactory: ExoPlaybackStateHolder.Factory
) :
MediaLibrarySession.Callback,
PlaybackActionHandler.Callback,
MediaItemBrowser.Invalidator,
PlaybackStateManager.Listener {
private val waitJob = Job()
private val waitScope = CoroutineScope(waitJob + Dispatchers.Default)
private val exoHolder = exoHolderFactory.create()
private lateinit var actionFactory: ActionFactory
private val mediaNotificationProvider =
DefaultMediaNotificationProvider.Builder(context)
.setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE)
.setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK")
.setChannelName(R.string.lbl_playback)
.setPlayDrawableResourceId(R.drawable.ic_play_24)
.setPauseDrawableResourceId(R.drawable.ic_pause_24)
.setSkipNextDrawableResourceId(R.drawable.ic_skip_next_24)
.setSkipPrevDrawableResourceId(R.drawable.ic_skip_prev_24)
.setContentIntent(context.newMainPendingIntent())
.build()
.also { it.setSmallIcon(R.drawable.ic_auxio_24) }
private var foregroundListener: ForegroundListener? = null
lateinit var systemReceiver: SystemPlaybackReceiver
lateinit var mediaSession: MediaLibrarySession
private set
// --- MEDIASESSION CALLBACKS ---
fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession {
foregroundListener = listener
mediaSession = createSession(service)
service.addSession(mediaSession)
actionFactory = DefaultActionFactory(service)
playbackManager.addListener(this)
exoHolder.attach()
actionHandler.attach(this)
systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
ContextCompat.registerReceiver(
context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
widgetComponent.attach()
mediaItemBrowser.attach(this)
return mediaSession
}
fun handleTaskRemoved() {
if (!playbackManager.progression.isPlaying) {
playbackManager.endSession()
}
}
fun start(startedBy: Int) {
// At minimum we want to ensure an active playback state.
// TODO: Possibly also force to go foreground?
logD("Handling non-native start.")
val action =
when (startedBy) {
IntegerTable.START_ID_ACTIVITY -> null
IntegerTable.START_ID_TASKER ->
DeferredPlayback.RestoreState(
play = true, fallback = DeferredPlayback.ShuffleAll)
// External services using Auxio better know what they are doing.
else -> DeferredPlayback.RestoreState(play = false)
}
if (action != null) {
logD("Initing service fragment using action $action")
playbackManager.playDeferred(action)
}
}
fun hasNotification(): Boolean = exoHolder.sessionOngoing
fun createNotification(post: (MediaNotification) -> Unit) {
val notification =
mediaNotificationProvider.createNotification(
mediaSession, mediaSession.customLayout, actionFactory) { notification ->
post(wrapMediaNotification(notification))
}
post(wrapMediaNotification(notification))
}
fun release() {
waitJob.cancel()
mediaItemBrowser.release()
context.unregisterReceiver(systemReceiver)
widgetComponent.release()
actionHandler.release()
exoHolder.release()
playbackManager.removeListener(this)
mediaSession.release()
foregroundListener = null
}
private fun wrapMediaNotification(notification: MediaNotification): MediaNotification {
// Pulled from MediaNotificationManager: Need to specify MediaSession token manually
// in notification
val fwkToken =
mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token
notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
return notification
}
private fun createSession(service: MediaLibraryService) =
MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this).build()
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): ConnectionResult {
val sessionCommands =
actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS)
return ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(sessionCommands)
.setCustomLayout(actionHandler.createCustomLayout())
.build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> =
if (actionHandler.handleCommand(customCommand)) {
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
} else {
super.onCustomCommand(session, controller, customCommand, args)
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> =
Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params))
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> {
val result =
mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(result)
}
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
Futures.immediateFuture(
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val children =
mediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError<ImmutableList<MediaItem>>(
LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(children)
}
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<Void>> =
waitScope
.async {
mediaItemBrowser.prepareSearch(query, browser)
// Invalidator will send the notify result
LibraryResult.ofVoid()
}
.asListenableFuture()
override fun onGetSearchResult(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: MediaLibraryService.LibraryParams?
) =
waitScope
.async {
mediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
}
.asListenableFuture()
override fun onSessionEnded() {
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
override fun onCustomLayoutChanged(layout: List<CommandButton>) {
mediaSession.setCustomLayout(layout)
}
override fun invalidate(ids: Map<String, Int>) {
for (id in ids) {
mediaSession.notifyChildrenChanged(id.key, id.value, null)
}
}
override fun invalidate(
controller: MediaSession.ControllerInfo,
query: String,
itemCount: Int
) {
mediaSession.notifySearchResultChanged(controller, query, itemCount, null)
}
}

View file

@ -18,142 +18,7 @@
package org.oxycblt.auxio.playback.service
import android.content.Context
import android.os.Bundle
import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionCommands
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.RepeatMode
class PlaybackActionHandler
@Inject
constructor(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings
) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
interface Callback {
fun onCustomLayoutChanged(layout: List<CommandButton>)
}
private var callback: Callback? = null
fun attach(callback: Callback) {
this.callback = callback
playbackManager.addListener(this)
playbackSettings.registerListener(this)
}
fun release() {
callback = null
playbackManager.removeListener(this)
playbackSettings.unregisterListener(this)
}
fun withCommands(commands: SessionCommands) =
commands
.buildUpon()
.add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
.add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
.add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY))
.build()
fun handleCommand(command: SessionCommand): Boolean {
when (command.customAction) {
PlaybackActions.ACTION_INC_REPEAT_MODE ->
playbackManager.repeatMode(playbackManager.repeatMode.increment())
PlaybackActions.ACTION_INVERT_SHUFFLE ->
playbackManager.shuffled(!playbackManager.isShuffled)
PlaybackActions.ACTION_EXIT -> playbackManager.endSession()
else -> return false
}
return true
}
fun createCustomLayout(): List<CommandButton> {
val actions = mutableListOf<CommandButton>()
when (playbackSettings.notificationAction) {
ActionMode.REPEAT -> {
actions.add(
CommandButton.Builder()
.setIconResId(playbackManager.repeatMode.icon)
.setDisplayName(context.getString(R.string.desc_change_repeat))
.setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle()))
.setEnabled(true)
.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(context.getString(R.string.lbl_shuffle))
.setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle()))
.setEnabled(true)
.build())
}
else -> {}
}
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_skip_prev_24)
.setDisplayName(context.getString(R.string.desc_skip_prev))
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
.setEnabled(true)
.build())
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_close_24)
.setDisplayName(context.getString(R.string.desc_exit))
.setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle()))
.setEnabled(true)
.build())
return actions
}
override fun onPauseOnRepeatChanged() {
super.onPauseOnRepeatChanged()
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onProgressionChanged(progression: Progression) {
super.onProgressionChanged(progression)
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
super.onRepeatModeChanged(repeatMode)
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
super.onQueueReordered(queue, index, isShuffled)
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onNotificationActionChanged() {
super.onNotificationActionChanged()
callback?.onCustomLayoutChanged(createCustomLayout())
}
}
object PlaybackActions {
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"

View file

@ -0,0 +1,212 @@
/*
* Copyright (c) 2024 Auxio Project
* MediaSessionServiceFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.service
import android.annotation.SuppressLint
import android.content.Context
import android.support.v4.media.session.MediaSessionCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.Job
import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.system.MediaSessionHolder
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent
class PlaybackServiceFragment
@Inject
constructor(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val sessionHolderFactory: MediaSessionHolder.Factory,
private val widgetComponent: WidgetComponent,
exoHolderFactory: ExoPlaybackStateHolder.Factory
) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener {
private val waitJob = Job()
private val exoHolder = exoHolderFactory.create()
private var foregroundListener: ForegroundListener? = null
private lateinit var sessionHolder: MediaSessionHolder
private lateinit var systemReceiver: SystemPlaybackReceiver
// --- MEDIASESSION CALLBACKS ---
@SuppressLint("WrongConstant")
fun attach(listener: ForegroundListener): MediaSessionCompat.Token {
foregroundListener = listener
playbackManager.addListener(this)
exoHolder.attach()
sessionHolder = sessionHolderFactory.create(context)
systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
ContextCompat.registerReceiver(
context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
widgetComponent.attach()
return sessionHolder.token
}
fun handleTaskRemoved() {
if (!playbackManager.progression.isPlaying) {
playbackManager.endSession()
}
}
fun start(startedBy: Int) {
// At minimum we want to ensure an active playback state.
// TODO: Possibly also force to go foreground?
logD("Handling non-native start.")
val action =
when (startedBy) {
IntegerTable.START_ID_ACTIVITY -> null
IntegerTable.START_ID_TASKER ->
DeferredPlayback.RestoreState(
play = true, fallback = DeferredPlayback.ShuffleAll)
// External services using Auxio better know what they are doing.
else -> DeferredPlayback.RestoreState(play = false)
}
if (action != null) {
logD("Initing service fragment using action $action")
playbackManager.playDeferred(action)
}
}
val notification: ForegroundServiceNotification?
get() = if (exoHolder.sessionOngoing) sessionHolder.notification else null
fun release() {
waitJob.cancel()
widgetComponent.release()
context.unregisterReceiver(systemReceiver)
sessionHolder.release()
exoHolder.release()
playbackManager.removeListener(this)
foregroundListener = null
}
override fun onSessionEnded() {
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
// override fun onConnect(
// session: MediaSession,
// controller: MediaSession.ControllerInfo
// ): ConnectionResult {
// val sessionCommands =
// actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS)
// return ConnectionResult.AcceptedResultBuilder(session)
// .setAvailableSessionCommands(sessionCommands)
// .setCustomLayout(actionHandler.createCustomLayout())
// .build()
// }
//
// override fun onCustomCommand(
// session: MediaSession,
// controller: MediaSession.ControllerInfo,
// customCommand: SessionCommand,
// args: Bundle
// ): ListenableFuture<SessionResult> =
// if (actionHandler.handleCommand(customCommand)) {
// Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
// } else {
// super.onCustomCommand(session, controller, customCommand, args)
// }
//
// override fun onGetLibraryRoot(
// session: MediaLibrarySession,
// browser: MediaSession.ControllerInfo,
// params: MediaLibraryService.LibraryParams?
// ): ListenableFuture<LibraryResult<MediaItem>> =
// Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params))
//
// override fun onGetItem(
// session: MediaLibrarySession,
// browser: MediaSession.ControllerInfo,
// mediaId: String
// ): ListenableFuture<LibraryResult<MediaItem>> {
// val result =
// mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
// ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
// return Futures.immediateFuture(result)
// }
//
// override fun onSetMediaItems(
// mediaSession: MediaSession,
// controller: MediaSession.ControllerInfo,
// mediaItems: MutableList<MediaItem>,
// startIndex: Int,
// startPositionMs: Long
// ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
// Futures.immediateFuture(
// MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
//
// override fun onGetChildren(
// session: MediaLibrarySession,
// browser: MediaSession.ControllerInfo,
// parentId: String,
// page: Int,
// pageSize: Int,
// params: MediaLibraryService.LibraryParams?
// ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
// val children =
// mediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
// LibraryResult.ofItemList(it, params)
// }
// ?: LibraryResult.ofError<ImmutableList<MediaItem>>(
// LibraryResult.RESULT_ERROR_BAD_VALUE)
// return Futures.immediateFuture(children)
// }
//
// override fun onSearch(
// session: MediaLibrarySession,
// browser: MediaSession.ControllerInfo,
// query: String,
// params: MediaLibraryService.LibraryParams?
// ): ListenableFuture<LibraryResult<Void>> =
// waitScope
// .async {
// mediaItemBrowser.prepareSearch(query, browser)
// // Invalidator will send the notify result
// LibraryResult.ofVoid()
// }
// .asListenableFuture()
//
// override fun onGetSearchResult(
// session: MediaLibrarySession,
// browser: MediaSession.ControllerInfo,
// query: String,
// page: Int,
// pageSize: Int,
// params: MediaLibraryService.LibraryParams?
// ) =
// waitScope
// .async {
// mediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
// LibraryResult.ofItemList(it, params)
// }
// ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
// }
// .asListenableFuture()
}

View file

@ -1,3 +1,21 @@
/*
* Copyright (c) 2024 Auxio Project
* SystemPlaybackReceiver.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
@ -109,4 +127,4 @@ class SystemPlaybackReceiver(
playbackManager.playing(false)
}
}
}
}