music: re-add music browsing
This commit is contained in:
parent
69070e7b13
commit
b1e871c6e1
5 changed files with 383 additions and 326 deletions
|
@ -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
|
||||
|
|
146
app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt
Normal file
146
app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt
Normal 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
|
||||
}
|
||||
}
|
|
@ -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()) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue