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.os.Bundle
import android.os.IBinder
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
@ -37,16 +36,16 @@ import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
@AndroidEntryPoint
class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
@Inject lateinit var mediaSessionFragment: PlaybackServiceFragment
class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
@Inject lateinit var playbackFragment: PlaybackServiceFragment
@Inject lateinit var indexingFragment: MusicServiceFragment
@Inject lateinit var musicFragment: MusicServiceFragment
@SuppressLint("WrongConstant")
override fun onCreate() {
super.onCreate()
setSessionToken(mediaSessionFragment.attach(this))
indexingFragment.attach(this)
sessionToken = playbackFragment.attach(this)
musicFragment.attach(this, this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -63,26 +62,31 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
indexingFragment.start()
mediaSessionFragment.start(startId)
musicFragment.start()
playbackFragment.start(startId)
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
mediaSessionFragment.handleTaskRemoved()
playbackFragment.handleTaskRemoved()
}
override fun onDestroy() {
super.onDestroy()
indexingFragment.release()
mediaSessionFragment.release()
musicFragment.release()
playbackFragment.release()
sessionToken = null
}
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? = null
): BrowserRoot = musicFragment.getRoot()
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
musicFragment.getItem(itemId, result)
}
override fun onLoadChildren(
parentId: String,
@ -93,13 +97,8 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
parentId: String,
result: Result<MutableList<MediaItem>>,
options: Bundle
) {
super.onLoadChildren(parentId, result, options)
}
) = musicFragment.getChildren(parentId, result)
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
super.onLoadItem(itemId, result)
}
override fun onSearch(
query: String,
@ -120,7 +119,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
}
override fun updateForeground(change: ForegroundListener.Change) {
val mediaNotification = mediaSessionFragment.notification
val mediaNotification = playbackFragment.notification
if (mediaNotification != null) {
if (change == ForegroundListener.Change.MEDIA_SESSION) {
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
// index during playback.
} else {
indexingFragment.createNotification {
musicFragment.createNotification {
if (it != null) {
startForeground(it.code, it.build())
isForeground = true
@ -140,6 +139,10 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
}
}
override fun invalidateMusic(mediaId: String) {
notifyChildrenChanged(mediaId)
}
companion object {
var isForeground = false
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
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.media.utils.MediaConstants
import androidx.media3.common.MediaMetadata
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
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.resolveNames
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?) {
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 {
// TODO: Make custom overflow menu for compat
val style =
val extras =
Bundle().apply {
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
@ -121,32 +122,41 @@ fun Category.toMediaItem(context: Context): MediaItem {
val description = MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString())
.setTitle(context.getString(nameRes))
.setExtras(extras)
if (bitmapRes != null) {
val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes)
description.setIconBitmap(bitmap)
}
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 =
if (parent == null) {
MediaSessionUID.SingleItem(uid)
} else {
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()
.setMediaId(mediaSessionUID.toString())
.setTitle(name.resolve(context))
@ -156,7 +166,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
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 counts =
context.getString(
@ -180,7 +190,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
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 counts =
if (songs.isNotEmpty()) {
@ -197,7 +207,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
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 counts =
if (songs.isNotEmpty()) {

View file

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

View file

@ -19,7 +19,12 @@
package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.Bundle
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 dagger.hilt.android.qualifiers.ApplicationContext
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.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
class MusicServiceFragment
@Inject
constructor(
@ApplicationContext override val workerContext: Context,
private val playbackManager: PlaybackStateManager,
@ApplicationContext context: Context,
private val indexer: Indexer,
private val browser: MusicBrowser,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings,
private val contentObserver: SystemContentObserver,
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 val indexingNotification = IndexingNotification(workerContext)
private val observingNotification = ObservingNotification(workerContext)
) : MusicBrowser.Invalidator, MusicSettings.Listener {
private val indexingNotification = IndexingNotification(context)
private val observingNotification = ObservingNotification(context)
private var invalidator: Invalidator? = 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)
interface Invalidator {
fun invalidateMusic(mediaId: String)
}
fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) {
this.invalidator = invalidator
indexer.attach(foregroundListener)
browser.attach(this)
contentObserver.attach()
musicSettings.registerListener(this)
}
fun release() {
musicSettings.unregisterListener(this)
contentObserver.release()
musicSettings.registerListener(this)
musicRepository.addIndexingListener(this)
musicRepository.addUpdateListener(this)
musicRepository.removeIndexingListener(this)
foregroundListener = null
browser.release()
indexer.release()
invalidator = 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() {
if (musicRepository.indexingState == null) {
requestIndex(true)
musicRepository.requestIndex(true)
}
}
@ -108,84 +124,24 @@ constructor(
}
}
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)
}
fun getRoot() = BrowserRoot(Category.ROOT.id, null)
override val scope = indexScope
fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result<MediaItem>) =
result.dispatch { browser.getItem(mediaId) }
override fun onIndexingStateChanged() {
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
wakeLock.acquireSafe()
} else {
wakeLock.releaseSafe()
fun getChildren(mediaId: String, result: MediaBrowserServiceCompat.Result<MutableList<MediaItem>>) =
result.dispatch { browser.getChildren(mediaId)?.toMutableList() }
private fun <T> MediaBrowserServiceCompat.Result<T>.dispatch(body: () -> T?) {
try {
val result = body()
if (result == null) {
logW("Result is null")
}
sendResult(result)
} catch (e: Exception) {
logD("Error while dispatching: $e")
sendResult(null)
}
}
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)
}
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
}
}