diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 777cb5ddb..b1a9da89b 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -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) { + musicFragment.getItem(itemId, result) + } override fun onLoadChildren( parentId: String, @@ -93,13 +97,8 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { parentId: String, result: Result>, options: Bundle - ) { - super.onLoadChildren(parentId, result, options) - } + ) = musicFragment.getChildren(parentId, result) - override fun onLoadItem(itemId: String, result: Result) { - 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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt new file mode 100644 index 000000000..398afdbb5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -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 . + */ + +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 + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 043aa0d46..afe6c3eda 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -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()) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 62238c5b9..542095a90 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -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() - private val searchResults = mutableMapOf>() - private var invalidator: Invalidator? = null - interface Invalidator { - fun invalidate(ids: Map) - - fun invalidate(controller: String, query: String, itemCount: Int) + fun invalidateMusic(ids: Set) } + 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() + val invalidate = mutableSetOf() 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? { + fun getChildren(parentId: String): 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) + return getMediaItemList(parentId, deviceLibrary, userLibrary) } private fun getMediaItemList( @@ -175,32 +145,34 @@ constructor( 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) } + 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? { - 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 - } +// 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? { +// 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) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 4616daf13..ae7f8a3ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -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) { + 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) = + 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>) = + result.dispatch { browser.getChildren(mediaId)?.toMutableList() } + + private fun MediaBrowserServiceCompat.Result.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 - } }