diff --git a/app/build.gradle b/app/build.gradle index 98ded55d6..2312bcb38 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index f0352d523..9c62fdcd2 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -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> + ) = throw NotImplementedError() + + override fun onLoadChildren( + parentId: String, + result: Result>, + options: Bundle + ) { + super.onLoadChildren(parentId, result, options) + } + + override fun onLoadItem(itemId: String, result: Result) { + super.onLoadItem(itemId, result) + } + + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + 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) +} diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index ab5474c9c..530f3f14f 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -79,7 +79,7 @@ class MainActivity : AppCompatActivity() { } } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) startIntentAction(intent) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index d857ab32b..0e895196d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 93841a63f..5d578119a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -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() - private val searchResults = mutableMapOf>() - private var invalidator: Invalidator? = null - - interface Invalidator { - fun invalidate(ids: Map) - - 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() - 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? { - 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? { - 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? { - 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? { - val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } - return deferred.await().concat().paginate(page, pageSize) - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - 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.paginate(page: Int, pageSize: Int): List? { - 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() +// private val searchResults = mutableMapOf>() +// private var invalidator: Invalidator? = null +// +// interface Invalidator { +// fun invalidate(ids: Map) +// +// 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() +// 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? { +// 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? { +// 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? { +// 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? { +// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } +// return deferred.await().concat().paginate(page, pageSize) +// } +// +// private fun SearchEngine.Items.concat(): MutableList { +// val music = mutableListOf() +// 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.paginate(page: Int, pageSize: Int): List? { +// 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) +// } +// } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 571e96ca7..4616daf13 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -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: diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt new file mode 100644 index 000000000..4c8c367fc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -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 . + */ + +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, 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, 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, + 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) { + 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) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt deleted file mode 100644 index a4be02ad2..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ /dev/null @@ -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 . - */ - -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 = - 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> = - Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - 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, - startIndex: Int, - startPositionMs: Long - ): ListenableFuture = - 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>> { - val children = - mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { - LibraryResult.ofItemList(it, params) - } - ?: LibraryResult.ofError>( - LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(children) - } - - override fun onSearch( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - params: MediaLibraryService.LibraryParams? - ): ListenableFuture> = - 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) { - mediaSession.setCustomLayout(layout) - } - - override fun invalidate(ids: Map) { - 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) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 3dd0acf68..441bf5253 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -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) - } - - 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 { - val actions = mutableListOf() - - 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, 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" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt new file mode 100644 index 000000000..91d6d8b77 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -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 . + */ + +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 = + // 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> = + // Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) + // + // override fun onGetItem( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // mediaId: String + // ): ListenableFuture> { + // 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, + // startIndex: Int, + // startPositionMs: Long + // ): ListenableFuture = + // 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>> { + // val children = + // mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { + // LibraryResult.ofItemList(it, params) + // } + // ?: LibraryResult.ofError>( + // LibraryResult.RESULT_ERROR_BAD_VALUE) + // return Futures.immediateFuture(children) + // } + // + // override fun onSearch( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // query: String, + // params: MediaLibraryService.LibraryParams? + // ): ListenableFuture> = + // 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() + +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index 31c931fdb..d671219a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -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 . + */ + package org.oxycblt.auxio.playback.service import android.content.BroadcastReceiver @@ -109,4 +127,4 @@ class SystemPlaybackReceiver( playbackManager.playing(false) } } -} \ No newline at end of file +}