music: decouple library from musicstore/indexer
De-couple the library data structure (and library grouping) from MusicStore and Indexer. This should make library creation *much* easier to test.
This commit is contained in:
parent
a5ea4af5c4
commit
a29875b5bf
12 changed files with 218 additions and 254 deletions
|
@ -32,13 +32,7 @@ import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.Sort
|
|
||||||
import org.oxycblt.auxio.music.storage.MimeType
|
import org.oxycblt.auxio.music.storage.MimeType
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
@ -137,7 +131,7 @@ class DetailViewModel(application: Application) :
|
||||||
musicStore.removeListener(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library == null) {
|
if (library == null) {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
|
|
|
@ -333,10 +333,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCompleteState(
|
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
|
||||||
binding: FragmentHomeBinding,
|
|
||||||
result: Result<MusicStore.Library>
|
|
||||||
) {
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
logD("Received ok response")
|
logD("Received ok response")
|
||||||
binding.homeFab.show()
|
binding.homeFab.show()
|
||||||
|
|
|
@ -24,13 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.home.tabs.Tab
|
import org.oxycblt.auxio.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.Sort
|
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -104,7 +98,7 @@ class HomeViewModel(application: Application) :
|
||||||
settings.removeListener(this)
|
settings.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
logD("Library changed, refreshing library")
|
logD("Library changed, refreshing library")
|
||||||
// Get the each list of items in the library to use as our list data.
|
// Get the each list of items in the library to use as our list data.
|
||||||
|
|
|
@ -38,7 +38,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Listener {
|
||||||
musicStore.addListener(this)
|
musicStore.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library == null) {
|
if (library == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
183
app/src/main/java/org/oxycblt/auxio/music/Library.kt
Normal file
183
app/src/main/java/org/oxycblt/auxio/music/Library.kt
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||||
|
import org.oxycblt.auxio.music.storage.useQuery
|
||||||
|
import org.oxycblt.auxio.settings.Settings
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organized music library information.
|
||||||
|
*
|
||||||
|
* This class allows for the creation of a well-formed music library graph from raw song
|
||||||
|
* information. It's generally not expected to create this yourself and instead use [MusicStore].
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart
|
||||||
|
*/
|
||||||
|
class Library(rawSongs: List<Song.Raw>, settings: Settings) {
|
||||||
|
/** All [Song]s that were detected on the device. */
|
||||||
|
val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) })
|
||||||
|
/** All [Album]s found on the device. */
|
||||||
|
val albums = buildAlbums(songs)
|
||||||
|
/** All [Artist]s found on the device. */
|
||||||
|
val artists = buildArtists(songs, albums)
|
||||||
|
/** All [Genre]s found on the device. */
|
||||||
|
val genres = buildGenres(songs)
|
||||||
|
|
||||||
|
private val uidMap = buildMap {
|
||||||
|
// We need to finalize the newly-created music and also add it to a mapping to make
|
||||||
|
// de-serializing music from UIDs much faster. Do these in the same loop for efficiency.
|
||||||
|
for (music in (songs + albums + artists + genres)) {
|
||||||
|
music._finalize()
|
||||||
|
this[music.uid] = music
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a [Music] item [T] in the library by it's [Music.UID].
|
||||||
|
* @param uid The [Music.UID] to search for.
|
||||||
|
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
|
||||||
|
* the [Music.UID] did not correspond to a [T].
|
||||||
|
*/
|
||||||
|
@Suppress("UNCHECKED_CAST") fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a [Song] from an another library into a [Song] in this [Library].
|
||||||
|
* @param song The [Song] to convert.
|
||||||
|
* @return The analogous [Song] in this [Library], or null if it does not exist.
|
||||||
|
*/
|
||||||
|
fun sanitize(song: Song) = find<Song>(song.uid)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a [Album] from an another library into a [Album] in this [Library].
|
||||||
|
* @param album The [Album] to convert.
|
||||||
|
* @return The analogous [Album] in this [Library], or null if it does not exist.
|
||||||
|
*/
|
||||||
|
fun sanitize(album: Album) = find<Album>(album.uid)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a [Artist] from an another library into a [Artist] in this [Library].
|
||||||
|
* @param artist The [Artist] to convert.
|
||||||
|
* @return The analogous [Artist] in this [Library], or null if it does not exist.
|
||||||
|
*/
|
||||||
|
fun sanitize(artist: Artist) = find<Artist>(artist.uid)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a [Genre] from an another library into a [Genre] in this [Library].
|
||||||
|
* @param genre The [Genre] to convert.
|
||||||
|
* @return The analogous [Genre] in this [Library], or null if it does not exist.
|
||||||
|
*/
|
||||||
|
fun sanitize(genre: Genre) = find<Genre>(genre.uid)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
|
||||||
|
* @param context [Context] required to analyze the [Uri].
|
||||||
|
* @param uri [Uri] to search for.
|
||||||
|
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
|
||||||
|
*/
|
||||||
|
fun findSongForUri(context: Context, uri: Uri) =
|
||||||
|
context.contentResolverSafe.useQuery(
|
||||||
|
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
|
||||||
|
// song. Do what we can to hopefully find the song the user wanted to open.
|
||||||
|
val displayName =
|
||||||
|
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||||
|
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
|
||||||
|
songs.find { it.path.name == displayName && it.size == size }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a list of [Album]s from the given [Song]s.
|
||||||
|
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
|
||||||
|
* [Album]s when created.
|
||||||
|
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
|
||||||
|
* with parent [Artist] instances in order to be usable.
|
||||||
|
*/
|
||||||
|
private fun buildAlbums(songs: List<Song>): List<Album> {
|
||||||
|
// Group songs by their singular raw album, then map the raw instances and their
|
||||||
|
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
|
||||||
|
val songsByAlbum = songs.groupBy { it._rawAlbum }
|
||||||
|
val albums = songsByAlbum.map { Album(it.key, it.value) }
|
||||||
|
logD("Successfully built ${albums.size} albums")
|
||||||
|
return albums
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
|
||||||
|
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by
|
||||||
|
* artist names, and [Album]s being grouped primarily by album artist names.
|
||||||
|
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
|
||||||
|
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
||||||
|
* created.
|
||||||
|
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
|
||||||
|
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
||||||
|
* created.
|
||||||
|
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
|
||||||
|
* of [Song]s and [Album]s.
|
||||||
|
*/
|
||||||
|
private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
|
||||||
|
// Add every raw artist credited to each Song/Album to the grouping. This way,
|
||||||
|
// different multi-artist combinations are not treated as different artists.
|
||||||
|
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
|
||||||
|
|
||||||
|
for (song in songs) {
|
||||||
|
for (rawArtist in song._rawArtists) {
|
||||||
|
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (album in albums) {
|
||||||
|
for (rawArtist in album._rawArtists) {
|
||||||
|
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the combined mapping into artist instances.
|
||||||
|
val artists = musicByArtist.map { Artist(it.key, it.value) }
|
||||||
|
logD("Successfully built ${artists.size} artists")
|
||||||
|
return artists
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group up [Song]s into [Genre] instances.
|
||||||
|
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
|
||||||
|
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
|
||||||
|
* created.
|
||||||
|
* @return A non-empty list of [Genre]s.
|
||||||
|
*/
|
||||||
|
private fun buildGenres(songs: List<Song>): List<Genre> {
|
||||||
|
// Add every raw genre credited to each Song to the grouping. This way,
|
||||||
|
// different multi-genre combinations are not treated as different genres.
|
||||||
|
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
|
||||||
|
for (song in songs) {
|
||||||
|
for (rawGenre in song._rawGenres) {
|
||||||
|
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the mapping into genre instances.
|
||||||
|
val genres = songsByGenre.map { Genre(it.key, it.value) }
|
||||||
|
logD("Successfully built ${genres.size} genres")
|
||||||
|
return genres
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,14 +17,8 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
|
||||||
import org.oxycblt.auxio.music.storage.useQuery
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A repository granting access to the music library..
|
* A repository granting access to the music library.
|
||||||
*
|
*
|
||||||
* This can be used to obtain certain music items, or await changes to the music library. It is
|
* This can be used to obtain certain music items, or await changes to the music library. It is
|
||||||
* generally recommended to use this over Indexer to keep track of the library state, as the
|
* generally recommended to use this over Indexer to keep track of the library state, as the
|
||||||
|
@ -72,101 +66,6 @@ class MusicStore private constructor() {
|
||||||
listeners.remove(listener)
|
listeners.remove(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A library of [Music] instances.
|
|
||||||
* @param songs All [Song]s loaded from the device.
|
|
||||||
* @param albums All [Album]s that could be created.
|
|
||||||
* @param artists All [Artist]s that could be created.
|
|
||||||
* @param genres All [Genre]s that could be created.
|
|
||||||
*/
|
|
||||||
data class Library(
|
|
||||||
val songs: List<Song>,
|
|
||||||
val albums: List<Album>,
|
|
||||||
val artists: List<Artist>,
|
|
||||||
val genres: List<Genre>,
|
|
||||||
) {
|
|
||||||
private val uidMap = HashMap<Music.UID, Music>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
// The data passed to Library initially are complete, but are still volitaile.
|
|
||||||
// Finalize them to ensure they are well-formed. Also initialize the UID map in
|
|
||||||
// the same loop for efficiency.
|
|
||||||
for (song in songs) {
|
|
||||||
song._finalize()
|
|
||||||
uidMap[song.uid] = song
|
|
||||||
}
|
|
||||||
|
|
||||||
for (album in albums) {
|
|
||||||
album._finalize()
|
|
||||||
uidMap[album.uid] = album
|
|
||||||
}
|
|
||||||
|
|
||||||
for (artist in artists) {
|
|
||||||
artist._finalize()
|
|
||||||
uidMap[artist.uid] = artist
|
|
||||||
}
|
|
||||||
|
|
||||||
for (genre in genres) {
|
|
||||||
genre._finalize()
|
|
||||||
uidMap[genre.uid] = genre
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a [Music] item [T] in the library by it's [Music.UID].
|
|
||||||
* @param uid The [Music.UID] to search for.
|
|
||||||
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found
|
|
||||||
* or the [Music.UID] did not correspond to a [T].
|
|
||||||
*/
|
|
||||||
@Suppress("UNCHECKED_CAST") fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a [Song] from an another library into a [Song] in this [Library].
|
|
||||||
* @param song The [Song] to convert.
|
|
||||||
* @return The analogous [Song] in this [Library], or null if it does not exist.
|
|
||||||
*/
|
|
||||||
fun sanitize(song: Song) = find<Song>(song.uid)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a [Album] from an another library into a [Album] in this [Library].
|
|
||||||
* @param album The [Album] to convert.
|
|
||||||
* @return The analogous [Album] in this [Library], or null if it does not exist.
|
|
||||||
*/
|
|
||||||
fun sanitize(album: Album) = find<Album>(album.uid)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a [Artist] from an another library into a [Artist] in this [Library].
|
|
||||||
* @param artist The [Artist] to convert.
|
|
||||||
* @return The analogous [Artist] in this [Library], or null if it does not exist.
|
|
||||||
*/
|
|
||||||
fun sanitize(artist: Artist) = find<Artist>(artist.uid)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a [Genre] from an another library into a [Genre] in this [Library].
|
|
||||||
* @param genre The [Genre] to convert.
|
|
||||||
* @return The analogous [Genre] in this [Library], or null if it does not exist.
|
|
||||||
*/
|
|
||||||
fun sanitize(genre: Genre) = find<Genre>(genre.uid)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
|
|
||||||
* @param context [Context] required to analyze the [Uri].
|
|
||||||
* @param uri [Uri] to search for.
|
|
||||||
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
|
|
||||||
*/
|
|
||||||
fun findSongForUri(context: Context, uri: Uri) =
|
|
||||||
context.contentResolverSafe.useQuery(
|
|
||||||
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
|
|
||||||
cursor.moveToFirst()
|
|
||||||
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
|
|
||||||
// song. Do what we can to hopefully find the song the user wanted to open.
|
|
||||||
val displayName =
|
|
||||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
|
||||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
|
|
||||||
songs.find { it.path.name == displayName && it.size == size }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A listener for changes in the music library. */
|
/** A listener for changes in the music library. */
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -50,7 +50,7 @@ class PickerViewModel : ViewModel(), MusicStore.Listener {
|
||||||
musicStore.removeListener(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
refreshChoices()
|
refreshChoices()
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,17 +24,10 @@ import android.os.Build
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.Sort
|
|
||||||
import org.oxycblt.auxio.music.extractor.*
|
import org.oxycblt.auxio.music.extractor.*
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -52,7 +45,7 @@ import org.oxycblt.auxio.util.logW
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Indexer private constructor() {
|
class Indexer private constructor() {
|
||||||
@Volatile private var lastResponse: Result<MusicStore.Library>? = null
|
@Volatile private var lastResponse: Result<Library>? = null
|
||||||
@Volatile private var indexingState: Indexing? = null
|
@Volatile private var indexingState: Indexing? = null
|
||||||
@Volatile private var controller: Controller? = null
|
@Volatile private var controller: Controller? = null
|
||||||
@Volatile private var listener: Listener? = null
|
@Volatile private var listener: Listener? = null
|
||||||
|
@ -198,11 +191,11 @@ class Indexer private constructor() {
|
||||||
* @param context [Context] required to load music.
|
* @param context [Context] required to load music.
|
||||||
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
|
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
|
||||||
* be written, but no cache entries will be loaded into the new library.
|
* be written, but no cache entries will be loaded into the new library.
|
||||||
* @return A newly-loaded [MusicStore.Library].
|
* @return A newly-loaded [Library].
|
||||||
* @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
|
* @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
|
||||||
* @throws NoMusicException If no music was found on the device.
|
* @throws NoMusicException If no music was found on the device.
|
||||||
*/
|
*/
|
||||||
private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library {
|
private suspend fun indexImpl(context: Context, withCache: Boolean): Library {
|
||||||
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||||
PackageManager.PERMISSION_DENIED) {
|
PackageManager.PERMISSION_DENIED) {
|
||||||
// No permissions, signal that we can't do anything.
|
// No permissions, signal that we can't do anything.
|
||||||
|
@ -218,7 +211,6 @@ class Indexer private constructor() {
|
||||||
} else {
|
} else {
|
||||||
WriteOnlyCacheExtractor(context)
|
WriteOnlyCacheExtractor(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaStoreExtractor =
|
val mediaStoreExtractor =
|
||||||
when {
|
when {
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
||||||
|
@ -227,33 +219,24 @@ class Indexer private constructor() {
|
||||||
Api29MediaStoreExtractor(context, cacheDatabase)
|
Api29MediaStoreExtractor(context, cacheDatabase)
|
||||||
else -> Api21MediaStoreExtractor(context, cacheDatabase)
|
else -> Api21MediaStoreExtractor(context, cacheDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
||||||
|
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() }
|
||||||
val songs =
|
|
||||||
buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() }
|
|
||||||
// Build the rest of the music library from the song list. This is much more powerful
|
// Build the rest of the music library from the song list. This is much more powerful
|
||||||
// and reliable compared to using MediaStore to obtain grouping information.
|
// and reliable compared to using MediaStore to obtain grouping information.
|
||||||
val buildStart = System.currentTimeMillis()
|
val buildStart = System.currentTimeMillis()
|
||||||
val albums = buildAlbums(songs)
|
val library = Library(rawSongs, Settings(context))
|
||||||
val artists = buildArtists(songs, albums)
|
|
||||||
val genres = buildGenres(songs)
|
|
||||||
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
|
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
|
||||||
return MusicStore.Library(songs, albums, artists, genres)
|
return library
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a list of [Song]s from the device.
|
* Load a list of [Song]s from the device.
|
||||||
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
|
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
|
||||||
* instances.
|
* instances.
|
||||||
* @param settings [Settings] required to create [Song] instances.
|
|
||||||
* @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked
|
* @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked
|
||||||
* with parent [Album], [Artist], and [Genre] items in order to be usable.
|
* with parent [Album], [Artist], and [Genre] items in order to be usable.
|
||||||
*/
|
*/
|
||||||
private suspend fun buildSongs(
|
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> {
|
||||||
metadataExtractor: MetadataExtractor,
|
|
||||||
settings: Settings
|
|
||||||
): List<Song> {
|
|
||||||
logD("Starting indexing process")
|
logD("Starting indexing process")
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
|
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
|
||||||
|
@ -263,104 +246,23 @@ class Indexer private constructor() {
|
||||||
yield()
|
yield()
|
||||||
|
|
||||||
// Note: We use a set here so we can eliminate song duplicates.
|
// Note: We use a set here so we can eliminate song duplicates.
|
||||||
val songs = mutableSetOf<Song>()
|
|
||||||
val rawSongs = mutableListOf<Song.Raw>()
|
val rawSongs = mutableListOf<Song.Raw>()
|
||||||
metadataExtractor.extract().collect { rawSong ->
|
metadataExtractor.extract().collect { rawSong ->
|
||||||
songs.add(Song(rawSong, settings))
|
|
||||||
rawSongs.add(rawSong)
|
rawSongs.add(rawSong)
|
||||||
|
|
||||||
// Now we can signal a defined progress by showing how many songs we have
|
// Now we can signal a defined progress by showing how many songs we have
|
||||||
// loaded, and the projected amount of songs we found in the library
|
// loaded, and the projected amount of songs we found in the library
|
||||||
// (obtained by the extractors)
|
// (obtained by the extractors)
|
||||||
yield()
|
yield()
|
||||||
emitIndexing(Indexing.Songs(songs.size, total))
|
emitIndexing(Indexing.Songs(rawSongs.size, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize the extractors with the songs we have now loaded. There is no ETA
|
// Finalize the extractors with the songs we have now loaded. There is no ETA
|
||||||
// on this process, so go back to an indeterminate state.
|
// on this process, so go back to an indeterminate state.
|
||||||
emitIndexing(Indexing.Indeterminate)
|
emitIndexing(Indexing.Indeterminate)
|
||||||
metadataExtractor.finalize(rawSongs)
|
metadataExtractor.finalize(rawSongs)
|
||||||
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
|
logD(
|
||||||
|
"Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms")
|
||||||
// Ensure that sorting order is consistent so that grouping is also consistent.
|
return rawSongs
|
||||||
// Rolling this into the set is not an option, as songs with the same sort result
|
|
||||||
// would be lost.
|
|
||||||
return Sort(Sort.Mode.ByName, true).songs(songs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a list of [Album]s from the given [Song]s.
|
|
||||||
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
|
|
||||||
* [Album]s when created.
|
|
||||||
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
|
|
||||||
* with parent [Artist] instances in order to be usable.
|
|
||||||
*/
|
|
||||||
private fun buildAlbums(songs: List<Song>): List<Album> {
|
|
||||||
// Group songs by their singular raw album, then map the raw instances and their
|
|
||||||
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
|
|
||||||
val songsByAlbum = songs.groupBy { it._rawAlbum }
|
|
||||||
val albums = songsByAlbum.map { Album(it.key, it.value) }
|
|
||||||
logD("Successfully built ${albums.size} albums")
|
|
||||||
return albums
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
|
|
||||||
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by
|
|
||||||
* artist names, and [Album]s being grouped primarily by album artist names.
|
|
||||||
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
|
|
||||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
|
||||||
* created.
|
|
||||||
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
|
|
||||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
|
||||||
* created.
|
|
||||||
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
|
|
||||||
* of [Song]s and [Album]s.
|
|
||||||
*/
|
|
||||||
private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
|
|
||||||
// Add every raw artist credited to each Song/Album to the grouping. This way,
|
|
||||||
// different multi-artist combinations are not treated as different artists.
|
|
||||||
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
|
|
||||||
|
|
||||||
for (song in songs) {
|
|
||||||
for (rawArtist in song._rawArtists) {
|
|
||||||
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (album in albums) {
|
|
||||||
for (rawArtist in album._rawArtists) {
|
|
||||||
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert the combined mapping into artist instances.
|
|
||||||
val artists = musicByArtist.map { Artist(it.key, it.value) }
|
|
||||||
logD("Successfully built ${artists.size} artists")
|
|
||||||
return artists
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group up [Song]s into [Genre] instances.
|
|
||||||
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
|
|
||||||
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
|
|
||||||
* created.
|
|
||||||
* @return A non-empty list of [Genre]s.
|
|
||||||
*/
|
|
||||||
private fun buildGenres(songs: List<Song>): List<Genre> {
|
|
||||||
// Add every raw genre credited to each Song to the grouping. This way,
|
|
||||||
// different multi-genre combinations are not treated as different genres.
|
|
||||||
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
|
|
||||||
for (song in songs) {
|
|
||||||
for (rawGenre in song._rawGenres) {
|
|
||||||
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert the mapping into genre instances.
|
|
||||||
val genres = songsByGenre.map { Genre(it.key, it.value) }
|
|
||||||
logD("Successfully built ${genres.size} genres")
|
|
||||||
return genres
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -387,7 +289,7 @@ class Indexer private constructor() {
|
||||||
* @param result The new [Result] to emit, representing the outcome of the music loading
|
* @param result The new [Result] to emit, representing the outcome of the music loading
|
||||||
* process.
|
* process.
|
||||||
*/
|
*/
|
||||||
private suspend fun emitCompletion(result: Result<MusicStore.Library>) {
|
private suspend fun emitCompletion(result: Result<Library>) {
|
||||||
yield()
|
yield()
|
||||||
// Swap to the Main thread so that downstream callbacks don't crash from being on
|
// Swap to the Main thread so that downstream callbacks don't crash from being on
|
||||||
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
|
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
|
||||||
|
@ -418,7 +320,7 @@ class Indexer private constructor() {
|
||||||
* Music loading has completed.
|
* Music loading has completed.
|
||||||
* @param result The outcome of the music loading process.
|
* @param result The outcome of the music loading process.
|
||||||
*/
|
*/
|
||||||
data class Complete(val result: Result<MusicStore.Library>) : State()
|
data class Complete(val result: Result<Library>) : State()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -456,7 +358,7 @@ class Indexer private constructor() {
|
||||||
*
|
*
|
||||||
* This is only useful for code that absolutely must show the current loading process.
|
* This is only useful for code that absolutely must show the current loading process.
|
||||||
* Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of
|
* Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of
|
||||||
* the [MusicStore.Library].
|
* the [Library].
|
||||||
*/
|
*/
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -23,10 +23,7 @@ import android.database.sqlite.SQLiteDatabase
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
import android.provider.BaseColumns
|
import android.provider.BaseColumns
|
||||||
import androidx.core.database.sqlite.transaction
|
import androidx.core.database.sqlite.transaction
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -72,10 +69,10 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a persisted [SavedState] from the database.
|
* Read a persisted [SavedState] from the database.
|
||||||
* @param library [MusicStore.Library] required to restore [SavedState].
|
* @param library [Library] required to restore [SavedState].
|
||||||
* @return A persisted [SavedState], or null if one could not be found.
|
* @return A persisted [SavedState], or null if one could not be found.
|
||||||
*/
|
*/
|
||||||
fun read(library: MusicStore.Library): SavedState? {
|
fun read(library: Library): SavedState? {
|
||||||
requireBackgroundThread()
|
requireBackgroundThread()
|
||||||
// Read the saved state and queue. If the state is non-null, that must imply an
|
// Read the saved state and queue. If the state is non-null, that must imply an
|
||||||
// existent, albeit possibly empty, queue.
|
// existent, albeit possibly empty, queue.
|
||||||
|
@ -123,7 +120,7 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
||||||
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
|
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readQueue(library: MusicStore.Library): List<Song> {
|
private fun readQueue(library: Library): List<Song> {
|
||||||
val queue = mutableListOf<Song>()
|
val queue = mutableListOf<Song>()
|
||||||
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
|
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
|
||||||
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
|
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
|
||||||
|
|
|
@ -486,11 +486,11 @@ class PlaybackStateManager private constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the playback state to align with a new [MusicStore.Library].
|
* Update the playback state to align with a new [Library].
|
||||||
* @param newLibrary The new [MusicStore.Library] that was recently loaded.
|
* @param newLibrary The new [Library] that was recently loaded.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun sanitize(newLibrary: MusicStore.Library) {
|
fun sanitize(newLibrary: Library) {
|
||||||
// if (!isInitialized) {
|
// if (!isInitialized) {
|
||||||
// // Nothing playing, nothing to do.
|
// // Nothing playing, nothing to do.
|
||||||
// logD("Not initialized, no need to sanitize")
|
// logD("Not initialized, no need to sanitize")
|
||||||
|
|
|
@ -43,6 +43,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
|
import org.oxycblt.auxio.music.Library
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||||
|
@ -302,7 +303,7 @@ class PlaybackService :
|
||||||
|
|
||||||
// --- MUSICSTORE OVERRIDES ---
|
// --- MUSICSTORE OVERRIDES ---
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
// We now have a library, see if we have anything we need to do.
|
// We now have a library, see if we have anything we need to do.
|
||||||
playbackManager.requestAction(this)
|
playbackManager.requestAction(this)
|
||||||
|
|
|
@ -30,10 +30,7 @@ import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
|
||||||
import org.oxycblt.auxio.music.Sort
|
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -63,7 +60,7 @@ class SearchViewModel(application: Application) :
|
||||||
musicStore.removeListener(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
// Make sure our query is up to date with the music library.
|
// Make sure our query is up to date with the music library.
|
||||||
search(lastQuery)
|
search(lastQuery)
|
||||||
|
@ -96,7 +93,7 @@ class SearchViewModel(application: Application) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun searchImpl(library: MusicStore.Library, query: String): List<Item> {
|
private fun searchImpl(library: Library, query: String): List<Item> {
|
||||||
val sort = Sort(Sort.Mode.ByName, true)
|
val sort = Sort(Sort.Mode.ByName, true)
|
||||||
val filterMode = settings.searchFilterMode
|
val filterMode = settings.searchFilterMode
|
||||||
val results = mutableListOf<Item>()
|
val results = mutableListOf<Item>()
|
||||||
|
|
Loading…
Reference in a new issue