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 --- // --- THIRD PARTY ---
// Exoplayer (Vendored) // Exoplayer (Vendored)
implementation project(":media-lib-session")
implementation project(":media-lib-exoplayer") implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"

View file

@ -19,34 +19,35 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle
import android.os.IBinder 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.core.app.ServiceCompat
import androidx.media3.session.MediaLibraryService import androidx.media.MediaBrowserServiceCompat
import androidx.media3.session.MediaSession
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.service.IndexerServiceFragment import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
@AndroidEntryPoint @AndroidEntryPoint
class AuxioService : MediaLibraryService(), ForegroundListener { class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment @Inject lateinit var mediaSessionFragment: PlaybackServiceFragment
@Inject lateinit var indexingFragment: IndexerServiceFragment @Inject lateinit var indexingFragment: MusicServiceFragment
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
mediaSessionFragment.attach(this, this) setSessionToken(mediaSessionFragment.attach(this))
indexingFragment.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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached // TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here. // service, we might need more handling here.
@ -54,6 +55,11 @@ class AuxioService : MediaLibraryService(), ForegroundListener {
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
override fun onBind(intent: Intent): IBinder? {
onHandleForeground(intent)
return super.onBind(intent)
}
private fun onHandleForeground(intent: Intent?) { private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
indexingFragment.start() indexingFragment.start()
@ -71,20 +77,54 @@ class AuxioService : MediaLibraryService(), ForegroundListener {
mediaSessionFragment.release() mediaSessionFragment.release()
} }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = override fun onGetRoot(
mediaSessionFragment.mediaSession clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
TODO("Not yet implemented")
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { override fun onLoadChildren(
updateForeground(ForegroundListener.Change.MEDIA_SESSION) 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) { override fun updateForeground(change: ForegroundListener.Change) {
if (mediaSessionFragment.hasNotification()) { val mediaNotification = mediaSessionFragment.notification
if (mediaNotification != null) {
if (change == ForegroundListener.Change.MEDIA_SESSION) { if (change == ForegroundListener.Change.MEDIA_SESSION) {
mediaSessionFragment.createNotification { startForeground(mediaNotification.code, mediaNotification.build())
startForeground(it.notificationId, it.notification)
isForeground = true
}
} }
// Nothing changed, but don't show anything music related since we can always // Nothing changed, but don't show anything music related since we can always
// index during playback. // index during playback.
@ -118,3 +158,42 @@ interface ForegroundListener {
INDEXER 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) super.onNewIntent(intent)
startIntentAction(intent) startIntentAction(intent)
} }

View file

@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service
import android.content.Context import android.content.Context
import android.os.SystemClock import android.os.SystemClock
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundServiceNotification
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
@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
/** /**
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that * A dynamic [ForegroundServiceNotification] that shows the current music loading state.
* 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.
* *
* @param context [Context] required to create the notification. * @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class IndexingNotification(private val context: Context) : class IndexingNotification(private val context: Context) :
IndexerNotification(context, indexerChannel) { ForegroundServiceNotification(context, indexerChannel) {
private var lastUpdateTime = -1L private var lastUpdateTime = -1L
init { 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 * A static [ForegroundServiceNotification] that signals to the user that the app is currently
* music library for changes. * monitoring the music library for changes.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) { class ObservingNotification(context: Context) :
ForegroundServiceNotification(context, indexerChannel) {
init { init {
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE) setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel = private val indexerChannel =
IndexerNotification.ChannelInfo( ForegroundServiceNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

@ -17,358 +17,358 @@
*/ */
package org.oxycblt.auxio.music.service package org.oxycblt.auxio.music.service
//
import android.content.Context // import android.content.Context
import android.os.Bundle // import android.os.Bundle
import androidx.annotation.StringRes // import androidx.annotation.StringRes
import androidx.media.utils.MediaConstants // import androidx.media.utils.MediaConstants
import androidx.media3.common.MediaItem // import androidx.media3.common.MediaItem
import androidx.media3.session.MediaSession.ControllerInfo // import androidx.media3.session.MediaSession.ControllerInfo
import dagger.hilt.android.qualifiers.ApplicationContext // import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject // import javax.inject.Inject
import kotlin.math.min // import kotlin.math.min
import kotlinx.coroutines.CoroutineScope // import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred // import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers // import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job // import kotlinx.coroutines.Job
import kotlinx.coroutines.async // import kotlinx.coroutines.async
import org.oxycblt.auxio.R // import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings // import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort // import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album // import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist // import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre // import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music // import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository // import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist // import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song // import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary // import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.user.UserLibrary // import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.search.SearchEngine // import org.oxycblt.auxio.search.SearchEngine
//
class MediaItemBrowser // class MediaItemBrowser
@Inject // @Inject
constructor( // constructor(
@ApplicationContext private val context: Context, // @ApplicationContext private val context: Context,
private val musicRepository: MusicRepository, // private val musicRepository: MusicRepository,
private val listSettings: ListSettings, // private val listSettings: ListSettings,
private val searchEngine: SearchEngine // private val searchEngine: SearchEngine
) : MusicRepository.UpdateListener { // ) : MusicRepository.UpdateListener {
private val browserJob = Job() // private val browserJob = Job()
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) // private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
private val searchSubscribers = mutableMapOf<ControllerInfo, String>() // private val searchSubscribers = mutableMapOf<ControllerInfo, String>()
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>() // private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
private var invalidator: Invalidator? = null // private var invalidator: Invalidator? = null
//
interface Invalidator { // interface Invalidator {
fun invalidate(ids: Map<String, Int>) // fun invalidate(ids: Map<String, Int>)
//
fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) // fun invalidate(controller: ControllerInfo, query: String, itemCount: Int)
} // }
//
fun attach(invalidator: Invalidator) { // fun attach(invalidator: Invalidator) {
this.invalidator = invalidator // this.invalidator = invalidator
musicRepository.addUpdateListener(this) // musicRepository.addUpdateListener(this)
} // }
//
fun release() { // fun release() {
browserJob.cancel() // browserJob.cancel()
invalidator = null // invalidator = null
musicRepository.removeUpdateListener(this) // musicRepository.removeUpdateListener(this)
} // }
//
override fun onMusicChanges(changes: MusicRepository.Changes) { // override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary // val deviceLibrary = musicRepository.deviceLibrary
var invalidateSearch = false // var invalidateSearch = false
val invalidate = mutableMapOf<String, Int>() // val invalidate = mutableMapOf<String, Int>()
if (changes.deviceLibrary && deviceLibrary != null) { // if (changes.deviceLibrary && deviceLibrary != null) {
MediaSessionUID.Category.DEVICE_MUSIC.forEach { // MediaSessionUID.Category.DEVICE_MUSIC.forEach {
invalidate[it.toString()] = getCategorySize(it, musicRepository) // invalidate[it.toString()] = getCategorySize(it, musicRepository)
} // }
//
deviceLibrary.albums.forEach { // deviceLibrary.albums.forEach {
val id = MediaSessionUID.Single(it.uid).toString() // val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size // invalidate[id] = it.songs.size
} // }
//
deviceLibrary.artists.forEach { // deviceLibrary.artists.forEach {
val id = MediaSessionUID.Single(it.uid).toString() // val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size // invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size
} // }
//
deviceLibrary.genres.forEach { // deviceLibrary.genres.forEach {
val id = MediaSessionUID.Single(it.uid).toString() // val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size + it.artists.size // invalidate[id] = it.songs.size + it.artists.size
} // }
//
invalidateSearch = true // invalidateSearch = true
} // }
val userLibrary = musicRepository.userLibrary // val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) { // if (changes.userLibrary && userLibrary != null) {
MediaSessionUID.Category.USER_MUSIC.forEach { // MediaSessionUID.Category.USER_MUSIC.forEach {
invalidate[it.toString()] = getCategorySize(it, musicRepository) // invalidate[it.toString()] = getCategorySize(it, musicRepository)
} // }
userLibrary.playlists.forEach { // userLibrary.playlists.forEach {
val id = MediaSessionUID.Single(it.uid).toString() // val id = MediaSessionUID.Single(it.uid).toString()
invalidate[id] = it.songs.size // invalidate[id] = it.songs.size
} // }
invalidateSearch = true // invalidateSearch = true
} // }
//
if (invalidate.isNotEmpty()) { // if (invalidate.isNotEmpty()) {
invalidator?.invalidate(invalidate) // invalidator?.invalidate(invalidate)
} // }
//
if (invalidateSearch) { // if (invalidateSearch) {
for (entry in searchResults.entries) { // for (entry in searchResults.entries) {
searchResults[entry.key]?.cancel() // searchResults[entry.key]?.cancel()
} // }
searchResults.clear() // searchResults.clear()
//
for (entry in searchSubscribers.entries) { // for (entry in searchSubscribers.entries) {
if (searchResults[entry.value] != null) { // if (searchResults[entry.value] != null) {
continue // continue
} // }
searchResults[entry.value] = searchTo(entry.value) // searchResults[entry.value] = searchTo(entry.value)
} // }
} // }
} // }
//
val root: MediaItem // val root: MediaItem
get() = MediaSessionUID.Category.ROOT.toMediaItem(context) // get() = MediaSessionUID.Category.ROOT.toMediaItem(context)
//
fun getItem(mediaId: String): MediaItem? { // fun getItem(mediaId: String): MediaItem? {
val music = // val music =
when (val uid = MediaSessionUID.fromString(mediaId)) { // when (val uid = MediaSessionUID.fromString(mediaId)) {
is MediaSessionUID.Category -> return uid.toMediaItem(context) // is MediaSessionUID.Category -> return uid.toMediaItem(context)
is MediaSessionUID.Single -> // is MediaSessionUID.Single ->
musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } // musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) }
is MediaSessionUID.Joined -> // is MediaSessionUID.Joined ->
musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } // musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) }
null -> null // null -> null
} // }
?: return null // ?: return null
//
return when (music) { // return when (music) {
is Album -> music.toMediaItem(context) // is Album -> music.toMediaItem(context)
is Artist -> music.toMediaItem(context) // is Artist -> music.toMediaItem(context)
is Genre -> music.toMediaItem(context) // is Genre -> music.toMediaItem(context)
is Playlist -> music.toMediaItem(context) // is Playlist -> music.toMediaItem(context)
is Song -> music.toMediaItem(context, null) // is Song -> music.toMediaItem(context, null)
} // }
} // }
//
fun getChildren(parentId: String, page: Int, pageSize: Int): List<MediaItem>? { // fun getChildren(parentId: String, page: Int, pageSize: Int): List<MediaItem>? {
val deviceLibrary = musicRepository.deviceLibrary // val deviceLibrary = musicRepository.deviceLibrary
val userLibrary = musicRepository.userLibrary // val userLibrary = musicRepository.userLibrary
if (deviceLibrary == null || userLibrary == null) { // if (deviceLibrary == null || userLibrary == null) {
return listOf() // return listOf()
} // }
//
val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null // val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null
return items.paginate(page, pageSize) // return items.paginate(page, pageSize)
} // }
//
private fun getMediaItemList( // private fun getMediaItemList(
id: String, // id: String,
deviceLibrary: DeviceLibrary, // deviceLibrary: DeviceLibrary,
userLibrary: UserLibrary // userLibrary: UserLibrary
): List<MediaItem>? { // ): List<MediaItem>? {
return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { // return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
is MediaSessionUID.Category -> { // is MediaSessionUID.Category -> {
when (mediaSessionUID) { // when (mediaSessionUID) {
MediaSessionUID.Category.ROOT -> // MediaSessionUID.Category.ROOT ->
MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } // MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) }
MediaSessionUID.Category.SONGS -> // MediaSessionUID.Category.SONGS ->
listSettings.songSort.songs(deviceLibrary.songs).map { // listSettings.songSort.songs(deviceLibrary.songs).map {
it.toMediaItem(context, null) // it.toMediaItem(context, null)
} // }
MediaSessionUID.Category.ALBUMS -> // MediaSessionUID.Category.ALBUMS ->
listSettings.albumSort.albums(deviceLibrary.albums).map { // listSettings.albumSort.albums(deviceLibrary.albums).map {
it.toMediaItem(context) // it.toMediaItem(context)
} // }
MediaSessionUID.Category.ARTISTS -> // MediaSessionUID.Category.ARTISTS ->
listSettings.artistSort.artists(deviceLibrary.artists).map { // listSettings.artistSort.artists(deviceLibrary.artists).map {
it.toMediaItem(context) // it.toMediaItem(context)
} // }
MediaSessionUID.Category.GENRES -> // MediaSessionUID.Category.GENRES ->
listSettings.genreSort.genres(deviceLibrary.genres).map { // listSettings.genreSort.genres(deviceLibrary.genres).map {
it.toMediaItem(context) // it.toMediaItem(context)
} // }
MediaSessionUID.Category.PLAYLISTS -> // MediaSessionUID.Category.PLAYLISTS ->
userLibrary.playlists.map { it.toMediaItem(context) } // userLibrary.playlists.map { it.toMediaItem(context) }
} // }
} // }
is MediaSessionUID.Single -> { // is MediaSessionUID.Single -> {
getChildMediaItems(mediaSessionUID.uid) // getChildMediaItems(mediaSessionUID.uid)
} // }
is MediaSessionUID.Joined -> { // is MediaSessionUID.Joined -> {
getChildMediaItems(mediaSessionUID.childUid) // getChildMediaItems(mediaSessionUID.childUid)
} // }
null -> { // null -> {
return null // return null
} // }
} // }
} // }
//
private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? { // private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? {
return when (val item = musicRepository.find(uid)) { // return when (val item = musicRepository.find(uid)) {
is Album -> { // is Album -> {
val songs = listSettings.albumSongSort.songs(item.songs) // val songs = listSettings.albumSongSort.songs(item.songs)
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } // songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
} // }
is Artist -> { // is Artist -> {
val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) // val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
val songs = listSettings.artistSongSort.songs(item.songs) // val songs = listSettings.artistSongSort.songs(item.songs)
albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + // albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } +
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } // songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
} // }
is Genre -> { // is Genre -> {
val artists = GENRE_ARTISTS_SORT.artists(item.artists) // val artists = GENRE_ARTISTS_SORT.artists(item.artists)
val songs = listSettings.genreSongSort.songs(item.songs) // val songs = listSettings.genreSongSort.songs(item.songs)
artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + // artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } +
songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } // songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) }
} // }
is Playlist -> { // is Playlist -> {
item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } // item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
} // }
is Song, // is Song,
null -> return null // null -> return null
} // }
} // }
//
private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { // private fun MediaItem.withHeader(@StringRes res: Int): MediaItem {
val oldExtras = mediaMetadata.extras ?: Bundle() // val oldExtras = mediaMetadata.extras ?: Bundle()
val newExtras = // val newExtras =
Bundle(oldExtras).apply { // Bundle(oldExtras).apply {
putString( // putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, // MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
context.getString(res)) // context.getString(res))
} // }
return buildUpon() // return buildUpon()
.setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) // .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build())
.build() // .build()
} // }
//
private fun getCategorySize( // private fun getCategorySize(
category: MediaSessionUID.Category, // category: MediaSessionUID.Category,
musicRepository: MusicRepository // musicRepository: MusicRepository
): Int { // ): Int {
val deviceLibrary = musicRepository.deviceLibrary ?: return 0 // val deviceLibrary = musicRepository.deviceLibrary ?: return 0
val userLibrary = musicRepository.userLibrary ?: return 0 // val userLibrary = musicRepository.userLibrary ?: return 0
return when (category) { // return when (category) {
MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size // MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size
MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size // MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size
MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size // MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size
MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size // MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size
MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size // MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size
MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size // MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size
} // }
} // }
//
suspend fun prepareSearch(query: String, controller: ControllerInfo) { // suspend fun prepareSearch(query: String, controller: ControllerInfo) {
searchSubscribers[controller] = query // searchSubscribers[controller] = query
val existing = searchResults[query] // val existing = searchResults[query]
if (existing == null) { // if (existing == null) {
val new = searchTo(query) // val new = searchTo(query)
searchResults[query] = new // searchResults[query] = new
new.await() // new.await()
} else { // } else {
val items = existing.await() // val items = existing.await()
invalidator?.invalidate(controller, query, items.count()) // invalidator?.invalidate(controller, query, items.count())
} // }
} // }
//
suspend fun getSearchResult( // suspend fun getSearchResult(
query: String, // query: String,
page: Int, // page: Int,
pageSize: Int, // pageSize: Int,
): List<MediaItem>? { // ): List<MediaItem>? {
val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } // val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it }
return deferred.await().concat().paginate(page, pageSize) // return deferred.await().concat().paginate(page, pageSize)
} // }
//
private fun SearchEngine.Items.concat(): MutableList<MediaItem> { // private fun SearchEngine.Items.concat(): MutableList<MediaItem> {
val music = mutableListOf<MediaItem>() // val music = mutableListOf<MediaItem>()
if (songs != null) { // if (songs != null) {
music.addAll(songs.map { it.toMediaItem(context, null) }) // music.addAll(songs.map { it.toMediaItem(context, null) })
} // }
if (albums != null) { // if (albums != null) {
music.addAll(albums.map { it.toMediaItem(context) }) // music.addAll(albums.map { it.toMediaItem(context) })
} // }
if (artists != null) { // if (artists != null) {
music.addAll(artists.map { it.toMediaItem(context) }) // music.addAll(artists.map { it.toMediaItem(context) })
} // }
if (genres != null) { // if (genres != null) {
music.addAll(genres.map { it.toMediaItem(context) }) // music.addAll(genres.map { it.toMediaItem(context) })
} // }
if (playlists != null) { // if (playlists != null) {
music.addAll(playlists.map { it.toMediaItem(context) }) // music.addAll(playlists.map { it.toMediaItem(context) })
} // }
return music // return music
} // }
//
private fun SearchEngine.Items.count(): Int { // private fun SearchEngine.Items.count(): Int {
var count = 0 // var count = 0
if (songs != null) { // if (songs != null) {
count += songs.size // count += songs.size
} // }
if (albums != null) { // if (albums != null) {
count += albums.size // count += albums.size
} // }
if (artists != null) { // if (artists != null) {
count += artists.size // count += artists.size
} // }
if (genres != null) { // if (genres != null) {
count += genres.size // count += genres.size
} // }
if (playlists != null) { // if (playlists != null) {
count += playlists.size // count += playlists.size
} // }
return count // return count
} // }
//
private fun searchTo(query: String) = // private fun searchTo(query: String) =
searchScope.async { // searchScope.async {
if (query.isEmpty()) { // if (query.isEmpty()) {
return@async SearchEngine.Items() // return@async SearchEngine.Items()
} // }
val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() // val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items()
val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() // val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items()
val items = // val items =
SearchEngine.Items( // SearchEngine.Items(
deviceLibrary.songs, // deviceLibrary.songs,
deviceLibrary.albums, // deviceLibrary.albums,
deviceLibrary.artists, // deviceLibrary.artists,
deviceLibrary.genres, // deviceLibrary.genres,
userLibrary.playlists) // userLibrary.playlists)
val results = searchEngine.search(items, query) // val results = searchEngine.search(items, query)
for (entry in searchSubscribers.entries) { // for (entry in searchSubscribers.entries) {
if (entry.value == query) { // if (entry.value == query) {
invalidator?.invalidate(entry.key, query, results.count()) // invalidator?.invalidate(entry.key, query, results.count())
} // }
} // }
results // results
} // }
//
private fun List<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? { // private fun List<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? {
if (page == Int.MAX_VALUE) { // if (page == Int.MAX_VALUE) {
// I think if someone requests this page it more or less implies that I should // // I think if someone requests this page it more or less implies that I should
// return all of the pages. // // return all of the pages.
return this // return this
} // }
val start = page * pageSize // val start = page * pageSize
val end = min((page + 1) * pageSize, size) // Tolerate partial page queries // val end = min((page + 1) * pageSize, size) // Tolerate partial page queries
if (pageSize == 0 || start !in indices) { // if (pageSize == 0 || start !in indices) {
// These pages are probably invalid. Hopefully this won't backfire. // // These pages are probably invalid. Hopefully this won't backfire.
return null // return null
} // }
return subList(start, end).toMutableList() // return subList(start, end).toMutableList()
} // }
//
private companion object { // private companion object {
// TODO: Rely on detail item gen logic? // // TODO: Rely on detail item gen logic?
val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) // val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) // 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 kotlinx.coroutines.Job
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings 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.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
class IndexerServiceFragment class MusicServiceFragment
@Inject @Inject
constructor( constructor(
@ApplicationContext override val workerContext: Context, @ApplicationContext override val workerContext: Context,
@ -85,7 +86,7 @@ constructor(
} }
} }
fun createNotification(post: (IndexerNotification?) -> Unit) { fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
val state = musicRepository.indexingState val state = musicRepository.indexingState
if (state is IndexingState.Indexing) { if (state is IndexingState.Indexing) {
// There are a few reasons why we stay in the foreground with automatic rescanning: // 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 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.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 { object PlaybackActions {
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" 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 package org.oxycblt.auxio.playback.service
import android.content.BroadcastReceiver import android.content.BroadcastReceiver