service: drop media3 session entirely
This commit is contained in:
parent
c1e5adbc44
commit
e43f55bc78
11 changed files with 1308 additions and 862 deletions
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -79,7 +79,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
startIntentAction(intent)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
// }
|
||||
// }
|
||||
|
|
|
@ -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:
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue