music: re-add music browsing

This commit is contained in:
Alexander Capehart 2024-08-27 16:12:41 -06:00
parent 69070e7b13
commit b1e871c6e1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
5 changed files with 383 additions and 326 deletions

View file

@ -23,7 +23,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaBrowserCompat.MediaItem
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
@ -37,16 +36,16 @@ import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
@AndroidEntryPoint @AndroidEntryPoint
class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
@Inject lateinit var mediaSessionFragment: PlaybackServiceFragment @Inject lateinit var playbackFragment: PlaybackServiceFragment
@Inject lateinit var indexingFragment: MusicServiceFragment @Inject lateinit var musicFragment: MusicServiceFragment
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
setSessionToken(mediaSessionFragment.attach(this)) sessionToken = playbackFragment.attach(this)
indexingFragment.attach(this) musicFragment.attach(this, this)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -63,26 +62,31 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
private fun onHandleForeground(intent: Intent?) { private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
indexingFragment.start() musicFragment.start()
mediaSessionFragment.start(startId) playbackFragment.start(startId)
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
mediaSessionFragment.handleTaskRemoved() playbackFragment.handleTaskRemoved()
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
indexingFragment.release() musicFragment.release()
mediaSessionFragment.release() playbackFragment.release()
sessionToken = null
} }
override fun onGetRoot( override fun onGetRoot(
clientPackageName: String, clientPackageName: String,
clientUid: Int, clientUid: Int,
rootHints: Bundle? rootHints: Bundle?
): BrowserRoot? = null ): BrowserRoot = musicFragment.getRoot()
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
musicFragment.getItem(itemId, result)
}
override fun onLoadChildren( override fun onLoadChildren(
parentId: String, parentId: String,
@ -93,13 +97,8 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
parentId: String, parentId: String,
result: Result<MutableList<MediaItem>>, result: Result<MutableList<MediaItem>>,
options: Bundle options: Bundle
) { ) = musicFragment.getChildren(parentId, result)
super.onLoadChildren(parentId, result, options)
}
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
super.onLoadItem(itemId, result)
}
override fun onSearch( override fun onSearch(
query: String, query: String,
@ -120,7 +119,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
} }
override fun updateForeground(change: ForegroundListener.Change) { override fun updateForeground(change: ForegroundListener.Change) {
val mediaNotification = mediaSessionFragment.notification val mediaNotification = playbackFragment.notification
if (mediaNotification != null) { if (mediaNotification != null) {
if (change == ForegroundListener.Change.MEDIA_SESSION) { if (change == ForegroundListener.Change.MEDIA_SESSION) {
startForeground(mediaNotification.code, mediaNotification.build()) startForeground(mediaNotification.code, mediaNotification.build())
@ -128,7 +127,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
// Nothing changed, but don't show anything music related since we can always // Nothing changed, but don't show anything music related since we can always
// index during playback. // index during playback.
} else { } else {
indexingFragment.createNotification { musicFragment.createNotification {
if (it != null) { if (it != null) {
startForeground(it.code, it.build()) startForeground(it.code, it.build())
isForeground = true isForeground = true
@ -140,6 +139,10 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
} }
} }
override fun invalidateMusic(mediaId: String) {
notifyChildrenChanged(mediaId)
}
companion object { companion object {
var isForeground = false var isForeground = false
private set private set

View file

@ -0,0 +1,146 @@
/*
* Copyright (c) 2024 Auxio Project
* IndexerServiceFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.PowerManager
import coil.ImageLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
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
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
class Indexer
@Inject
constructor(
@ApplicationContext override val workerContext: Context,
private val playbackManager: PlaybackStateManager,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings,
private val imageLoader: ImageLoader
) :
MusicRepository.IndexingWorker,
MusicRepository.IndexingListener,
MusicRepository.UpdateListener,
MusicSettings.Listener {
private val indexJob = Job()
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
private var foregroundListener: ForegroundListener? = null
private val wakeLock =
workerContext
.getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
fun attach(listener: ForegroundListener) {
foregroundListener = listener
musicSettings.registerListener(this)
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this)
musicRepository.registerWorker(this)
}
fun release() {
musicSettings.registerListener(this)
musicRepository.addIndexingListener(this)
musicRepository.addUpdateListener(this)
musicRepository.removeIndexingListener(this)
foregroundListener = null
}
override fun requestIndex(withCache: Boolean) {
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
// Cancel the previous music loading job.
currentIndexJob?.cancel()
// Start a new music loading job on a co-routine.
currentIndexJob = musicRepository.index(this, withCache)
}
override val scope = indexScope
override fun onIndexingStateChanged() {
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
wakeLock.acquireSafe()
} else {
wakeLock.releaseSafe()
}
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
savedState.copy(
heap =
savedState.heap.map { song ->
song?.let { deviceLibrary.findSong(it.uid) }
}),
true)
}
}
override fun onIndexingSettingChanged() {
super.onIndexingSettingChanged()
musicRepository.requestIndex(true)
}
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.acquireSafe() {
// Avoid unnecessary acquire calls.
if (!wakeLock.isHeld) {
logD("Acquiring wake lock")
// Time out after a minute, which is the average music loading time for a medium-sized
// library. If this runs out, we will re-request the lock, and if music loading is
// shorter than the timeout, it will be released early.
acquire(WAKELOCK_TIMEOUT_MS)
}
}
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.releaseSafe() {
// Avoid unnecessary release calls.
if (wakeLock.isHeld) {
logD("Releasing wake lock")
release()
}
}
companion object {
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
}
}

View file

@ -19,16 +19,13 @@
package org.oxycblt.auxio.music.service package org.oxycblt.auxio.music.service
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import androidx.media3.common.MediaMetadata
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -40,8 +37,6 @@ import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import java.io.ByteArrayOutputStream
import kotlin.math.ceil
enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) {
ROOT("root", R.string.info_app_name, null), ROOT("root", R.string.info_app_name, null),
@ -109,9 +104,15 @@ sealed interface MediaSessionUID {
} }
} }
typealias Sugar = Bundle.(Context) -> Unit
fun header(@StringRes nameRes: Int): Sugar = {
putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes))
}
fun Category.toMediaItem(context: Context): MediaItem { fun Category.toMediaItem(context: Context): MediaItem {
// TODO: Make custom overflow menu for compat // TODO: Make custom overflow menu for compat
val style = val extras =
Bundle().apply { Bundle().apply {
putInt( putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
@ -121,32 +122,41 @@ fun Category.toMediaItem(context: Context): MediaItem {
val description = MediaDescriptionCompat.Builder() val description = MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString()) .setMediaId(mediaSessionUID.toString())
.setTitle(context.getString(nameRes)) .setTitle(context.getString(nameRes))
.setExtras(extras)
if (bitmapRes != null) { if (bitmapRes != null) {
val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes)
description.setIconBitmap(bitmap) description.setIconBitmap(bitmap)
} }
return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE)
} }
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
fun Song.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem {
val mediaSessionUID =
if (parent == null) {
MediaSessionUID.SingleItem(uid)
} else {
MediaSessionUID.ChildItem(parent.uid, uid)
}
val extras = Bundle().apply { sugar.forEach { this.it(context) } }
val description = MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString())
.setTitle(name.resolve(context))
.setSubtitle(artists.resolveNames(context))
.setDescription(album.name.resolve(context))
.setIconUri(album.cover.single.mediaStoreCoverUri)
.setMediaUri(uri)
.setExtras(extras)
.build()
return MediaItem(description, MediaItem.FLAG_PLAYABLE)
}
fun Album.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem {
val mediaSessionUID = val mediaSessionUID =
if (parent == null) { if (parent == null) {
MediaSessionUID.SingleItem(uid) MediaSessionUID.SingleItem(uid)
} else { } else {
MediaSessionUID.ChildItem(parent.uid, uid) MediaSessionUID.ChildItem(parent.uid, uid)
} }
val description = MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString())
.setTitle(name.resolve(context))
.setSubtitle(artists.resolveNames(context))
.setDescription(album.name.resolve(context))
.setIconUri(album.cover.single.mediaStoreCoverUri)
.setMediaUri(uri)
.build()
return MediaItem(description, MediaItem.FLAG_PLAYABLE)
}
fun Album.toMediaItem(context: Context): MediaItem {
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
val description = MediaDescriptionCompat.Builder() val description = MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString()) .setMediaId(mediaSessionUID.toString())
.setTitle(name.resolve(context)) .setTitle(name.resolve(context))
@ -156,7 +166,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
return MediaItem(description, MediaItem.FLAG_BROWSABLE) return MediaItem(description, MediaItem.FLAG_BROWSABLE)
} }
fun Artist.toMediaItem(context: Context): MediaItem { fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
val mediaSessionUID = MediaSessionUID.SingleItem(uid) val mediaSessionUID = MediaSessionUID.SingleItem(uid)
val counts = val counts =
context.getString( context.getString(
@ -180,7 +190,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
return MediaItem(description, MediaItem.FLAG_BROWSABLE) return MediaItem(description, MediaItem.FLAG_BROWSABLE)
} }
fun Genre.toMediaItem(context: Context): MediaItem { fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
val mediaSessionUID = MediaSessionUID.SingleItem(uid) val mediaSessionUID = MediaSessionUID.SingleItem(uid)
val counts = val counts =
if (songs.isNotEmpty()) { if (songs.isNotEmpty()) {
@ -197,7 +207,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
return MediaItem(description, MediaItem.FLAG_BROWSABLE) return MediaItem(description, MediaItem.FLAG_BROWSABLE)
} }
fun Playlist.toMediaItem(context: Context): MediaItem { fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
val mediaSessionUID = MediaSessionUID.SingleItem(uid) val mediaSessionUID = MediaSessionUID.SingleItem(uid)
val counts = val counts =
if (songs.isNotEmpty()) { if (songs.isNotEmpty()) {

View file

@ -45,100 +45,71 @@ import org.oxycblt.auxio.search.SearchEngine
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
class MediaItemBrowser class MusicBrowser
@Inject @Inject
constructor( constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val listSettings: ListSettings private val listSettings: ListSettings
) : MusicRepository.UpdateListener { ) : MusicRepository.UpdateListener {
private val browserJob = Job()
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
private val searchSubscribers = mutableMapOf<String, String>()
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
private var invalidator: Invalidator? = null
interface Invalidator { interface Invalidator {
fun invalidate(ids: Map<String, Int>) fun invalidateMusic(ids: Set<String>)
fun invalidate(controller: String, query: String, itemCount: Int)
} }
private var invalidator: Invalidator? = null
fun attach(invalidator: Invalidator) { fun attach(invalidator: Invalidator) {
this.invalidator = invalidator this.invalidator = invalidator
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
} }
fun release() { fun release() {
browserJob.cancel()
invalidator = null
musicRepository.removeUpdateListener(this) musicRepository.removeUpdateListener(this)
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary val deviceLibrary = musicRepository.deviceLibrary
var invalidateSearch = false val invalidate = mutableSetOf<String>()
val invalidate = mutableMapOf<String, Int>()
if (changes.deviceLibrary && deviceLibrary != null) { if (changes.deviceLibrary && deviceLibrary != null) {
MediaSessionUID.Category.DEVICE_MUSIC.forEach { Category.DEVICE_MUSIC.forEach {
invalidate[it.toString()] = getCategorySize(it, musicRepository) invalidate.add(MediaSessionUID.CategoryItem(it).toString())
} }
deviceLibrary.albums.forEach { deviceLibrary.albums.forEach {
val id = MediaSessionUID.SingleItem(it.uid).toString() val id = MediaSessionUID.SingleItem(it.uid).toString()
invalidate[id] = it.songs.size invalidate.add(id)
} }
deviceLibrary.artists.forEach { deviceLibrary.artists.forEach {
val id = MediaSessionUID.SingleItem(it.uid).toString() val id = MediaSessionUID.SingleItem(it.uid).toString()
invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size invalidate.add(id)
} }
deviceLibrary.genres.forEach { deviceLibrary.genres.forEach {
val id = MediaSessionUID.SingleItem(it.uid).toString() val id = MediaSessionUID.SingleItem(it.uid).toString()
invalidate[id] = it.songs.size + it.artists.size invalidate.add(id)
} }
invalidateSearch = true
} }
val userLibrary = musicRepository.userLibrary val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) { if (changes.userLibrary && userLibrary != null) {
MediaSessionUID.Category.USER_MUSIC.forEach { Category.USER_MUSIC.forEach {
invalidate[it.toString()] = getCategorySize(it, musicRepository) invalidate.add(MediaSessionUID.CategoryItem(it).toString())
} }
userLibrary.playlists.forEach { userLibrary.playlists.forEach {
val id = MediaSessionUID.SingleItem(it.uid).toString() val id = MediaSessionUID.SingleItem(it.uid).toString()
invalidate[id] = it.songs.size invalidate.add(id)
} }
invalidateSearch = true
} }
if (invalidate.isNotEmpty()) { if (invalidate.isNotEmpty()) {
invalidator?.invalidate(invalidate) invalidator?.invalidateMusic(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? { fun getItem(mediaId: String): MediaItem? {
val music = val music =
when (val uid = MediaSessionUID.fromString(mediaId)) { when (val uid = MediaSessionUID.fromString(mediaId)) {
is MediaSessionUID.Category -> return uid.toMediaItem(context) is MediaSessionUID.CategoryItem -> return uid.category.toMediaItem(context)
is MediaSessionUID.SingleItem -> is MediaSessionUID.SingleItem ->
musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) }
@ -158,15 +129,14 @@ constructor(
} }
} }
fun getChildren(parentId: String, page: Int, pageSize: Int): List<MediaItem>? { fun getChildren(parentId: String): List<MediaItem>? {
val deviceLibrary = musicRepository.deviceLibrary val deviceLibrary = musicRepository.deviceLibrary
val userLibrary = musicRepository.userLibrary val userLibrary = musicRepository.userLibrary
if (deviceLibrary == null || userLibrary == null) { if (deviceLibrary == null || userLibrary == null) {
return listOf() return listOf()
} }
val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null return getMediaItemList(parentId, deviceLibrary, userLibrary)
return items.paginate(page, pageSize)
} }
private fun getMediaItemList( private fun getMediaItemList(
@ -175,32 +145,34 @@ constructor(
userLibrary: UserLibrary userLibrary: UserLibrary
): List<MediaItem>? { ): List<MediaItem>? {
return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
is MediaSessionUID.Category -> { is MediaSessionUID.CategoryItem -> {
when (mediaSessionUID) { when (mediaSessionUID.category) {
MediaSessionUID.Category.ROOT -> Category.ROOT ->
MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } Category.IMPORTANT.map { it.toMediaItem(context) }
MediaSessionUID.Category.SONGS -> Category.MORE -> TODO()
Category.SONGS ->
listSettings.songSort.songs(deviceLibrary.songs).map { listSettings.songSort.songs(deviceLibrary.songs).map {
it.toMediaItem(context, null) it.toMediaItem(context, null)
} }
MediaSessionUID.Category.ALBUMS -> Category.ALBUMS ->
listSettings.albumSort.albums(deviceLibrary.albums).map { listSettings.albumSort.albums(deviceLibrary.albums).map {
it.toMediaItem(context) it.toMediaItem(context)
} }
MediaSessionUID.Category.ARTISTS -> Category.ARTISTS ->
listSettings.artistSort.artists(deviceLibrary.artists).map { listSettings.artistSort.artists(deviceLibrary.artists).map {
it.toMediaItem(context) it.toMediaItem(context)
} }
MediaSessionUID.Category.GENRES -> Category.GENRES ->
listSettings.genreSort.genres(deviceLibrary.genres).map { listSettings.genreSort.genres(deviceLibrary.genres).map {
it.toMediaItem(context) it.toMediaItem(context)
} }
MediaSessionUID.Category.PLAYLISTS -> Category.PLAYLISTS ->
userLibrary.playlists.map { it.toMediaItem(context) } userLibrary.playlists.map { it.toMediaItem(context) }
} }
} }
@ -223,25 +195,25 @@ constructor(
return when (val item = musicRepository.find(uid)) { return when (val item = musicRepository.find(uid)) {
is Album -> { is Album -> {
val songs = listSettings.albumSongSort.songs(item.songs) val songs = listSettings.albumSongSort.songs(item.songs)
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs))}
} }
is Artist -> { is Artist -> {
val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
val songs = listSettings.artistSongSort.songs(item.songs) val songs = listSettings.artistSongSort.songs(item.songs)
albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + albums.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } +
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) }
} }
is Genre -> { is Genre -> {
val artists = GENRE_ARTISTS_SORT.artists(item.artists) val artists = GENRE_ARTISTS_SORT.artists(item.artists)
val songs = listSettings.genreSongSort.songs(item.songs) val songs = listSettings.genreSongSort.songs(item.songs)
artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + artists.map { it.toMediaItem(context, header(R.string.lbl_songs)) } +
songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }
} }
is Playlist -> { is Playlist -> {
item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } item.songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }
} }
is Song, is Song,
@ -249,121 +221,91 @@ constructor(
} }
} }
private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { // suspend fun prepareSearch(query: String, controller: String) {
val oldExtras = mediaMetadata.extras ?: Bundle() // searchSubscribers[controller] = query
val newExtras = // val existing = searchResults[query]
Bundle(oldExtras).apply { // if (existing == null) {
putString( // val new = searchTo(query)
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, // searchResults[query] = new
context.getString(res) // new.await()
) // } else {
} // val items = existing.await()
return buildUpon() // invalidator?.invalidate(controller, query, items.count())
.setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) // }
.build() // }
} //
// suspend fun getSearchResult(
private fun getCategorySize( // query: String,
category: MediaSessionUID.Category, // page: Int,
musicRepository: MusicRepository // pageSize: Int,
): Int { // ): List<MediaItem>? {
val deviceLibrary = musicRepository.deviceLibrary ?: return 0 // val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it }
val userLibrary = musicRepository.userLibrary ?: return 0 // return deferred.await().concat().paginate(page, pageSize)
return when (category) { // }
MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size //
MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size // private fun SearchEngine.Items.concat(): MutableList<MediaItem> {
MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size // val music = mutableListOf<MediaItem>()
MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size // if (songs != null) {
MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size // music.addAll(songs.map { it.toMediaItem(context, null) })
MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size // }
} // if (albums != null) {
} // music.addAll(albums.map { it.toMediaItem(context) })
// }
suspend fun prepareSearch(query: String, controller: ControllerInfo) { // if (artists != null) {
searchSubscribers[controller] = query // music.addAll(artists.map { it.toMediaItem(context) })
val existing = searchResults[query] // }
if (existing == null) { // if (genres != null) {
val new = searchTo(query) // music.addAll(genres.map { it.toMediaItem(context) })
searchResults[query] = new // }
new.await() // if (playlists != null) {
} else { // music.addAll(playlists.map { it.toMediaItem(context) })
val items = existing.await() // }
invalidator?.invalidate(controller, query, items.count()) // return music
} // }
} //
// private fun SearchEngine.Items.count(): Int {
suspend fun getSearchResult( // var count = 0
query: String, // if (songs != null) {
page: Int, // count += songs.size
pageSize: Int, // }
): List<MediaItem>? { // if (albums != null) {
val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } // count += albums.size
return deferred.await().concat().paginate(page, pageSize) // }
} // if (artists != null) {
// count += artists.size
private fun SearchEngine.Items.concat(): MutableList<MediaItem> { // }
val music = mutableListOf<MediaItem>() // if (genres != null) {
if (songs != null) { // count += genres.size
music.addAll(songs.map { it.toMediaItem(context, null) }) // }
} // if (playlists != null) {
if (albums != null) { // count += playlists.size
music.addAll(albums.map { it.toMediaItem(context) }) // }
} // return count
if (artists != null) { // }
music.addAll(artists.map { it.toMediaItem(context) }) //
} // private fun searchTo(query: String) =
if (genres != null) { // searchScope.async {
music.addAll(genres.map { it.toMediaItem(context) }) // if (query.isEmpty()) {
} // return@async SearchEngine.Items()
if (playlists != null) { // }
music.addAll(playlists.map { it.toMediaItem(context) }) // val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items()
} // val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items()
return music // val items =
} // SearchEngine.Items(
// deviceLibrary.songs,
private fun SearchEngine.Items.count(): Int { // deviceLibrary.albums,
var count = 0 // deviceLibrary.artists,
if (songs != null) { // deviceLibrary.genres,
count += songs.size // userLibrary.playlists
} // )
if (albums != null) { // val results = searchEngine.search(items, query)
count += albums.size // for (entry in searchSubscribers.entries) {
} // if (entry.value == query) {
if (artists != null) { // invalidator?.invalidate(entry.key, query, results.count())
count += artists.size // }
} // }
if (genres != null) { // results
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>? { private fun List<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? {
if (page == Int.MAX_VALUE) { if (page == Int.MAX_VALUE) {

View file

@ -19,7 +19,12 @@
package org.oxycblt.auxio.music.service package org.oxycblt.auxio.music.service
import android.content.Context import android.content.Context
import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import androidx.media.MediaBrowserServiceCompat.BrowserRoot
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import android.support.v4.media.MediaBrowserCompat.MediaItem
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
@ -35,54 +40,65 @@ import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
class MusicServiceFragment class MusicServiceFragment
@Inject @Inject
constructor( constructor(
@ApplicationContext override val workerContext: Context, @ApplicationContext context: Context,
private val playbackManager: PlaybackStateManager, private val indexer: Indexer,
private val browser: MusicBrowser,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings, private val musicSettings: MusicSettings,
private val contentObserver: SystemContentObserver, private val contentObserver: SystemContentObserver,
private val imageLoader: ImageLoader ) : MusicBrowser.Invalidator, MusicSettings.Listener {
) : private val indexingNotification = IndexingNotification(context)
MusicRepository.IndexingWorker, private val observingNotification = ObservingNotification(context)
MusicRepository.IndexingListener, private var invalidator: Invalidator? = null
MusicRepository.UpdateListener,
MusicSettings.Listener {
private val indexJob = Job()
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
private val indexingNotification = IndexingNotification(workerContext)
private val observingNotification = ObservingNotification(workerContext)
private var foregroundListener: ForegroundListener? = null private var foregroundListener: ForegroundListener? = null
private val wakeLock =
workerContext
.getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
fun attach(listener: ForegroundListener) { interface Invalidator {
foregroundListener = listener fun invalidateMusic(mediaId: String)
musicSettings.registerListener(this) }
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this) fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) {
musicRepository.registerWorker(this) this.invalidator = invalidator
indexer.attach(foregroundListener)
browser.attach(this)
contentObserver.attach() contentObserver.attach()
musicSettings.registerListener(this)
} }
fun release() { fun release() {
musicSettings.unregisterListener(this)
contentObserver.release() contentObserver.release()
musicSettings.registerListener(this) browser.release()
musicRepository.addIndexingListener(this) indexer.release()
musicRepository.addUpdateListener(this) invalidator = null
musicRepository.removeIndexingListener(this) }
foregroundListener = null
override fun invalidateMusic(ids: Set<String>) {
ids.forEach { mediaId ->
requireNotNull(invalidator) { "Invalidator not available" }.invalidateMusic(mediaId)
}
}
override fun onObservingChanged() {
super.onObservingChanged()
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (musicRepository.indexingState == null) {
logD("Not loading, updating idle session")
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
}
} }
fun start() { fun start() {
if (musicRepository.indexingState == null) { if (musicRepository.indexingState == null) {
requestIndex(true) musicRepository.requestIndex(true)
} }
} }
@ -108,84 +124,24 @@ constructor(
} }
} }
override fun requestIndex(withCache: Boolean) { fun getRoot() = BrowserRoot(Category.ROOT.id, null)
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
// Cancel the previous music loading job.
currentIndexJob?.cancel()
// Start a new music loading job on a co-routine.
currentIndexJob = musicRepository.index(this, withCache)
}
override val scope = indexScope fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result<MediaItem>) =
result.dispatch { browser.getItem(mediaId) }
override fun onIndexingStateChanged() { fun getChildren(mediaId: String, result: MediaBrowserServiceCompat.Result<MutableList<MediaItem>>) =
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) result.dispatch { browser.getChildren(mediaId)?.toMutableList() }
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
wakeLock.acquireSafe()
} else {
wakeLock.releaseSafe()
}
}
override fun onMusicChanges(changes: MusicRepository.Changes) { private fun <T> MediaBrowserServiceCompat.Result<T>.dispatch(body: () -> T?) {
val deviceLibrary = musicRepository.deviceLibrary ?: return try {
logD("Music changed, updating shared objects") val result = body()
// Wipe possibly-invalidated outdated covers if (result == null) {
imageLoader.memoryCache?.clear() logW("Result is null")
// Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
savedState.copy(
heap =
savedState.heap.map { song ->
song?.let { deviceLibrary.findSong(it.uid) }
}),
true)
} }
sendResult(result)
} catch (e: Exception) {
logD("Error while dispatching: $e")
sendResult(null)
} }
override fun onIndexingSettingChanged() {
super.onIndexingSettingChanged()
musicRepository.requestIndex(true)
}
override fun onObservingChanged() {
super.onObservingChanged()
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (currentIndexJob == null) {
logD("Not loading, updating idle session")
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
}
}
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.acquireSafe() {
// Avoid unnecessary acquire calls.
if (!wakeLock.isHeld) {
logD("Acquiring wake lock")
// Time out after a minute, which is the average music loading time for a medium-sized
// library. If this runs out, we will re-request the lock, and if music loading is
// shorter than the timeout, it will be released early.
acquire(WAKELOCK_TIMEOUT_MS)
}
}
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.releaseSafe() {
// Avoid unnecessary release calls.
if (wakeLock.isHeld) {
logD("Releasing wake lock")
release()
}
}
companion object {
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
} }
} }