Heavily refactor music loading

Heavily change how music loading works so that artists arent redundantly loaded, genres are primarily song-based instead of genre based, and songs are now bound to their correct genres.
This commit is contained in:
OxygenCobalt 2020-12-20 15:30:50 -07:00
parent dc26d70088
commit 6e5bff3bd3
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
20 changed files with 264 additions and 488 deletions

View file

@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController
* instead of out of the app if a Detail Fragment is currently open. Also carries the
* multi-navigation fix.
* TODO: Migrate to a more powerful/efficient CoordinatorLayout instead of NestedScrollView
* TODO: Add custom artist images
* @author OxygenCobalt
*/
abstract class DetailFragment : Fragment() {

View file

@ -10,12 +10,13 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentGenreDetailBinding
import org.oxycblt.auxio.detail.adapters.GenreArtistAdapter
import org.oxycblt.auxio.detail.adapters.GenreSongAdapter
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setupArtistActions
import org.oxycblt.auxio.ui.setupSongActions
/**
* The [DetailFragment] for a genre.
@ -45,19 +46,13 @@ class GenreDetailFragment : DetailFragment() {
)
}
val artistAdapter = GenreArtistAdapter(
val songAdapter = GenreSongAdapter(
doOnClick = {
if (!detailModel.isNavigating) {
detailModel.updateNavigationStatus(true)
findNavController().navigate(
GenreDetailFragmentDirections.actionShowArtist(it.id)
)
}
playbackModel.playSong(it, PlaybackMode.IN_GENRE)
},
doOnLongClick = { data, view ->
PopupMenu(requireContext(), view).setupArtistActions(
data, playbackModel
PopupMenu(requireContext(), view).setupSongActions(
requireContext(), data, playbackModel
)
}
)
@ -96,13 +91,12 @@ class GenreDetailFragment : DetailFragment() {
}
}
// Disable the sort button if there is only one artist [Or less]
if (detailModel.currentGenre.value!!.artists.size < 2) {
if (detailModel.currentGenre.value!!.songs.size < 2) {
binding.genreSortButton.disable(requireContext())
}
binding.genreArtistRecycler.apply {
adapter = artistAdapter
binding.genreSongRecycler.apply {
adapter = songAdapter
setHasFixedSize(true)
}
@ -115,8 +109,8 @@ class GenreDetailFragment : DetailFragment() {
binding.genreSortButton.setImageResource(mode.iconRes)
// Then update the sort mode of the artist adapter.
artistAdapter.submitList(
mode.getSortedArtistList(detailModel.currentGenre.value!!.artists)
songAdapter.submitList(
mode.getSortedSongList(detailModel.currentGenre.value!!.songs)
)
}

View file

@ -1,41 +0,0 @@
package org.oxycblt.auxio.detail.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import org.oxycblt.auxio.databinding.ItemGenreArtistBinding
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
/**
* An adapter for displaying the [Artist]s of an genre.
*/
class GenreArtistAdapter(
private val doOnClick: (data: Artist) -> Unit,
private val doOnLongClick: (data: Artist, view: View) -> Unit
) : ListAdapter<Artist, GenreArtistAdapter.ViewHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemGenreArtistBinding.inflate(LayoutInflater.from(parent.context))
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
// Generic ViewHolder for an album
inner class ViewHolder(
private val binding: ItemGenreArtistBinding
) : BaseViewHolder<Artist>(binding, doOnClick, doOnLongClick) {
override fun onBind(data: Artist) {
binding.artist = data
binding.artistName.requestLayout()
}
}
}

View file

@ -0,0 +1,25 @@
package org.oxycblt.auxio.detail.adapters
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.viewholders.SongViewHolder
/**
* An adapter for displaying the [Song]s of a genre.
*/
class GenreSongAdapter(
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (data: Song, view: View) -> Unit
) : ListAdapter<Song, SongViewHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
return SongViewHolder.from(parent.context, doOnClick, doOnLongClick)
}
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
holder.bind(getItem(position))
}
}

View file

@ -15,7 +15,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentLoadingBinding
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.processing.MusicLoaderResponse
import org.oxycblt.auxio.music.processing.MusicLoader
/**
* An intermediary [Fragment] that asks for the READ_EXTERNAL_STORAGE permission and runs
@ -69,7 +69,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
// --- VIEWMODEL SETUP ---
loadingModel.response.observe(viewLifecycleOwner) {
if (it == MusicLoaderResponse.DONE) {
if (it == MusicLoader.Response.SUCCESS) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
@ -77,7 +77,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
// If the response wasn't a success, then show the specific error message
// depending on which error response was given, along with a retry button
binding.loadingErrorText.text =
if (it == MusicLoaderResponse.NO_MUSIC)
if (it == MusicLoader.Response.NO_MUSIC)
getString(R.string.error_no_music)
else
getString(R.string.error_music_load_failed)

View file

@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.processing.MusicLoaderResponse
import org.oxycblt.auxio.music.processing.MusicLoader
/**
* A [ViewModel] responsible for getting the music loading process going and managing the response
@ -18,8 +18,8 @@ import org.oxycblt.auxio.music.processing.MusicLoaderResponse
* @author OxygenCobalt
*/
class LoadingViewModel(private val app: Application) : ViewModel() {
private val mResponse = MutableLiveData<MusicLoaderResponse>()
val response: LiveData<MusicLoaderResponse> get() = mResponse
private val mResponse = MutableLiveData<MusicLoader.Response>()
val response: LiveData<MusicLoader.Response> get() = mResponse
private val mRedo = MutableLiveData<Boolean>()
val doReload: LiveData<Boolean> get() = mRedo

View file

@ -21,6 +21,7 @@ sealed class BaseModel {
* @property track The Song's Track number
* @property duration The duration of the song, in millis.
* @property album The Song's parent album. Use this instead of [albumId].
* @property genre The Song's [Genre]
* @property seconds The Song's duration in seconds
* @property formattedDuration The Song's duration as a duration string.
* @author OxygenCobalt
@ -32,7 +33,31 @@ data class Song(
val track: Int = -1,
val duration: Long = 0,
) : BaseModel() {
lateinit var album: Album
private var mAlbum: Album? = null
private var mGenre: Genre? = null
val genre: Genre? get() = mGenre
val album: Album get() {
val album = mAlbum
if (album != null) {
return album
} else {
error("Song $name must have an album")
}
}
fun applyGenre(genre: Genre) {
check(mGenre == null) { "Genre is already applied" }
mGenre = genre
}
fun applyAlbum(album: Album) {
check(mAlbum == null) { "Album is already applied" }
mAlbum = album
}
val seconds = duration / 1000
val formattedDuration: String = seconds.toDuration()
@ -40,24 +65,35 @@ data class Song(
/**
* The data object for an album. Inherits [BaseModel].
* @property artistId The Album's parent artist ID. Do not use this outside of attaching an album to its parent artist.
* @property coverUri The [Uri] for the album's cover. **Load this using Coil.**
* @property year The year this album was released. 0 if there is none in the metadata.
* @property artist The Album's parent [Artist]. use this instead of [artistId]
* @property songs The Album's child [Song]s.
* @property artistName The name of the parent artist. Do not use this outside of creating the artist from albums
* @property coverUri The [Uri] for the album's cover. **Load this using Coil.**
* @property year The year this album was released. 0 if there is none in the metadata.
* @property artist The Album's parent [Artist]. use this instead of [artistName]
* @property songs The Album's child [Song]s.
* @property totalDuration The combined duration of all of the album's child songs, formatted.
* @author OxygenCobalt
*/
data class Album(
override val id: Long = -1,
override val name: String,
val artistId: Long = -1,
val artistName: String,
val coverUri: Uri = Uri.EMPTY,
val year: Int = 0
) : BaseModel() {
lateinit var artist: Artist
private var mArtist: Artist? = null
val artist: Artist get() {
val artist = mArtist
if (artist != null) {
return artist
} else {
error("Album $name must have an artist")
}
}
private val mSongs = mutableListOf<Song>()
val songs: List<Song> get() = mSongs
val songs = mutableListOf<Song>()
val totalDuration: String by lazy {
var seconds: Long = 0
songs.forEach {
@ -65,21 +101,44 @@ data class Album(
}
seconds.toDuration()
}
fun applySongs(songs: List<Song>) {
songs.forEach {
it.applyAlbum(this)
mSongs.add(it)
}
}
fun applyArtist(artist: Artist) {
mArtist = artist
}
}
/**
* The data object for an artist. Inherits [BaseModel]
* @property albums The list of all [Album]s in this artist
* @property genres The list of all parent [Genre]s in this artist, sorted by relevance
* @property genre The most prominent genre for this artist
* @property songs The list of all [Song]s in this artist
* @author OxygenCobalt
*/
data class Artist(
override val id: Long = -1,
override var name: String
override var name: String,
val albums: List<Album>
) : BaseModel() {
val albums = mutableListOf<Album>()
val genres = mutableListOf<Genre>()
init {
albums.forEach {
it.applyArtist(this)
}
}
val genre: Genre? by lazy {
val groupedGenres = songs.groupBy { it.genre }
groupedGenres.keys.maxByOrNull { key ->
groupedGenres[key]?.size ?: 0
}
}
val songs: List<Song> by lazy {
val songs = mutableListOf<Song>()
@ -92,8 +151,6 @@ data class Artist(
/**
* The data object for a genre. Inherits [BaseModel]
* @property artists The list of all [Artist]s in this genre.
* @property albums The list of all [Album]s in this genre.
* @property songs The list of all [Song]s in this genre.
* @author OxygenCobalt
*/
@ -101,21 +158,20 @@ data class Genre(
override val id: Long = -1,
override var name: String,
) : BaseModel() {
val artists = mutableListOf<Artist>()
private val mSongs = mutableListOf<Song>()
val songs: List<Song> get() = mSongs
val albums: List<Album> by lazy {
val albums = mutableListOf<Album>()
artists.forEach {
albums.addAll(it.albums)
}
albums
val albumCount: Int by lazy {
songs.groupBy { it.album }.size
}
val songs: List<Song> by lazy {
val songs = mutableListOf<Song>()
artists.forEach {
songs.addAll(it.songs)
}
songs
val artistCount: Int by lazy {
songs.groupBy { it.album.artist }.size
}
fun addSong(song: Song) {
mSongs.add(song)
song.applyGenre(this)
}
}

View file

@ -3,16 +3,13 @@ package org.oxycblt.auxio.music
import android.app.Application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.R
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.processing.MusicLoader
import org.oxycblt.auxio.music.processing.MusicLoaderResponse
import org.oxycblt.auxio.music.processing.MusicSorter
import org.oxycblt.auxio.recycler.DisplayMode
/**
* The main storage for music items. Use [MusicStore.getInstance] to get the single instance of it.
* TODO: Completely rewrite this system.
* @author OxygenCobalt
*/
class MusicStore private constructor() {
@ -45,43 +42,25 @@ class MusicStore private constructor() {
* ***THIS SHOULD ONLY BE RAN FROM AN IO THREAD.***
* @param app [Application] required to load the music.
*/
suspend fun load(app: Application): MusicLoaderResponse {
suspend fun load(app: Application): MusicLoader.Response {
return withContext(Dispatchers.IO) {
this@MusicStore.logD("Starting initial music load...")
val start = System.currentTimeMillis()
// Get the placeholder strings, which are used by MusicLoader & MusicSorter for
// any music that doesn't have metadata.
val genrePlaceholder = app.getString(R.string.placeholder_genre)
val artistPlaceholder = app.getString(R.string.placeholder_artist)
val albumPlaceholder = app.getString(R.string.placeholder_album)
val loader = MusicLoader(app)
val response = loader.loadMusic()
val loader = MusicLoader(
app.contentResolver,
genrePlaceholder,
artistPlaceholder,
albumPlaceholder
)
if (loader.response == MusicLoaderResponse.DONE) {
if (response == MusicLoader.Response.SUCCESS) {
// If the loading succeeds, then sort the songs and update the value
val sorter = MusicSorter(
loader.genres,
loader.artists,
loader.albums,
loader.songs,
val sorter = MusicSorter(loader.songs, loader.albums)
genrePlaceholder,
artistPlaceholder,
albumPlaceholder
)
sorter.sort()
mSongs = sorter.songs.toList()
mAlbums = sorter.albums.toList()
mArtists = sorter.artists.toList()
mGenres = sorter.genres.toList()
mGenres = loader.genres.toList()
val elapsed = System.currentTimeMillis() - start
@ -90,7 +69,7 @@ class MusicStore private constructor() {
loaded = true
}
loader.response
response
}
}

View file

@ -111,26 +111,37 @@ fun Int.toYear(context: Context): String {
@BindingAdapter("genreCounts")
fun TextView.bindGenreCounts(genre: Genre) {
val artists = context.resources.getQuantityString(
R.plurals.format_artist_count, genre.artists.size, genre.artists.size
R.plurals.format_artist_count, genre.artistCount, genre.artistCount
)
val albums = context.resources.getQuantityString(
R.plurals.format_album_count, genre.albums.size, genre.albums.size
R.plurals.format_album_count, genre.albumCount, genre.albumCount
)
text = context.getString(R.string.format_double_counts, artists, albums)
}
/**
* Bind the album + song counts for a genre
*/
@BindingAdapter("altGenreCounts")
fun TextView.bindAltGenreCounts(genre: Genre) {
val albums = context.resources.getQuantityString(
R.plurals.format_album_count, genre.albumCount, genre.albumCount
)
val songs = context.resources.getQuantityString(
R.plurals.format_song_count, genre.songs.size, genre.songs.size
)
text = context.getString(R.string.format_double_counts, albums, songs)
}
/**
* Bind the most prominent artist genre
*/
// TODO: Add option to list all genres
@BindingAdapter("artistGenre")
fun TextView.bindArtistGenre(artist: Artist) {
text = if (artist.genres.isNotEmpty()) {
artist.genres[0].name
} else {
context.getString(R.string.placeholder_genre)
}
text = artist.genre?.name ?: context.getString(R.string.placeholder_genre)
}
/**

View file

@ -104,34 +104,24 @@ fun ImageView.bindArtistImage(artist: Artist) {
@BindingAdapter("genreImage")
fun ImageView.bindGenreImage(genre: Genre) {
val request: ImageRequest
val genreCovers = mutableListOf<Uri>()
if (genre.artists.size >= 4) {
val uris = mutableListOf<Uri>()
// Get the Nth cover from each artist, if possible.
for (i in 0..3) {
val artist = genre.artists[i]
uris.add(
if (artist.albums.size > i) {
artist.albums[i].coverUri
} else {
artist.albums[0].coverUri
}
)
}
genre.songs.groupBy { it.album }.forEach {
genreCovers.add(it.key.coverUri)
}
if (genreCovers.size >= 4) {
val fetcher = MosaicFetcher(context)
request = getDefaultRequest(context, this)
.data(uris)
.data(genreCovers.slice(0..3))
.fetcher(fetcher)
.error(R.drawable.ic_genre)
.build()
} else {
if (genre.artists.isNotEmpty()) {
if (genreCovers.isNotEmpty()) {
request = getDefaultRequest(context, this)
.data(genre.artists[0].albums[0].coverUri)
.data(genreCovers[0])
.error(R.drawable.ic_genre)
.build()
} else {

View file

@ -1,69 +1,47 @@
package org.oxycblt.auxio.music.processing
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.provider.MediaStore
import android.app.Application
import android.provider.MediaStore.Audio.Albums
import android.provider.MediaStore.Audio.Artists
import android.provider.MediaStore.Audio.Genres
import android.provider.MediaStore.Audio.Media
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.R
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toAlbumArtURI
import org.oxycblt.auxio.music.toNamedGenre
/** The response that [MusicLoader] gives when the process is done */
enum class MusicLoaderResponse {
DONE, FAILURE, NO_MUSIC
}
/**
* Object that loads music from the filesystem.
* TODO: Add custom artist images from the filesystem
* TODO: Move genre loading to songs [Loads would take longer though]
*/
class MusicLoader(
private val resolver: ContentResolver,
private val genrePlaceholder: String,
private val artistPlaceholder: String,
private val albumPlaceholder: String,
) {
class MusicLoader(private val app: Application) {
var genres = mutableListOf<Genre>()
var artists = mutableListOf<Artist>()
var albums = mutableListOf<Album>()
var songs = mutableListOf<Song>()
val response: MusicLoaderResponse
private val resolver = app.contentResolver
init {
response = findMusic()
}
private fun findMusic(): MusicLoaderResponse {
fun loadMusic(): Response {
try {
val start = System.currentTimeMillis()
loadGenres()
loadArtists()
loadAlbums()
loadSongs()
logD("Done in ${System.currentTimeMillis() - start}ms")
} catch (error: Exception) {
logE("Something went horribly wrong.")
error.printStackTrace()
return MusicLoaderResponse.FAILURE
return Response.FAILED
}
if (songs.size == 0) {
return MusicLoaderResponse.NO_MUSIC
return Response.NO_MUSIC
}
return MusicLoaderResponse.DONE
return Response.SUCCESS
}
private fun loadGenres() {
@ -80,6 +58,8 @@ class MusicLoader(
Genres.DEFAULT_SORT_ORDER
)
val genrePlaceholder = app.getString(R.string.placeholder_genre)
// And then process those into Genre objects
genreCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres._ID)
@ -87,9 +67,9 @@ class MusicLoader(
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
var name = cursor.getString(nameIndex) ?: genrePlaceholder
var name = cursor.getStringOrNull(nameIndex) ?: genrePlaceholder
// If a genre is still in an old int-based format [Android formats it as "(INT)"],mu
// If a genre is still in an old int-based format [Android formats it as "(INT)"],
// convert that to the corresponding ID3 genre.
if (name.contains(Regex("[0123456789)]"))) {
name = name.toNamedGenre() ?: genrePlaceholder
@ -108,73 +88,6 @@ class MusicLoader(
logD("Genre search finished with ${genres.size} genres found.")
}
private fun loadArtists() {
logD("Starting artist search...")
// Load all the artists
val artistCursor = resolver.query(
Artists.EXTERNAL_CONTENT_URI,
arrayOf(
Artists._ID, // 0
Artists.ARTIST // 1
),
null, null,
Artists.DEFAULT_SORT_ORDER
)
artistCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Artists._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Artists.ARTIST)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
var name = cursor.getString(nameIndex)
if (name == null || name == MediaStore.UNKNOWN_STRING) {
name = artistPlaceholder
}
artists.add(
Artist(
id, name
)
)
}
cursor.close()
}
artists = artists.distinctBy {
it.name to it.genres
}.toMutableList()
// Then try to associate any genres with their respective artists.
for (genre in genres) {
val artistGenreCursor = resolver.query(
Genres.Members.getContentUri("external", genre.id),
arrayOf(
Genres.Members.ARTIST_ID
),
null, null, null
)
artistGenreCursor?.let { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres.Members.ARTIST_ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
artists.filter { it.id == id }.forEach {
it.genres.add(genre)
}
}
}
artistGenreCursor?.close()
}
logD("Artist search finished with ${artists.size} artists found.")
}
@SuppressLint("InlinedApi")
private fun loadAlbums() {
logD("Starting album search...")
@ -184,7 +97,7 @@ class MusicLoader(
arrayOf(
Albums._ID, // 0
Albums.ALBUM, // 1
Albums.ARTIST_ID, // 2
Albums.ARTIST, // 2
Albums.FIRST_YEAR, // 3
),
@ -192,24 +105,26 @@ class MusicLoader(
Albums.DEFAULT_SORT_ORDER
)
val albumPlaceholder = app.getString(R.string.placeholder_album)
val artistPlaceholder = app.getString(R.string.placeholder_artist)
albumCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Albums._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Albums.ALBUM)
val artistIdIndex = cursor.getColumnIndexOrThrow(Albums.ARTIST_ID)
val artistIdIndex = cursor.getColumnIndexOrThrow(Albums.ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(Albums.FIRST_YEAR)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val name = cursor.getString(nameIndex) ?: albumPlaceholder
val artistId = cursor.getLong(artistIdIndex)
val artistName = cursor.getString(artistIdIndex) ?: artistPlaceholder
val year = cursor.getInt(yearIndex)
val coverUri = id.toAlbumArtURI()
albums.add(
Album(
id, name, artistId,
coverUri, year
id = id, name = name, artistName = artistName,
coverUri = coverUri, year = year
)
)
}
@ -218,7 +133,7 @@ class MusicLoader(
}
albums = albums.distinctBy {
it.name to it.artistId to it.year
it.name to it.artistName to it.year
}.toMutableList()
logD("Album search finished with ${albums.size} albums found")
@ -272,6 +187,34 @@ class MusicLoader(
it.name to it.albumId to it.track to it.duration
}.toMutableList()
// Then try to associate any genres with their respective songs
// This is stupidly inefficient, but I don't have another choice really.
for (genre in genres) {
val songGenreCursor = resolver.query(
Genres.Members.getContentUri("external", genre.id),
arrayOf(
Genres.Members._ID
),
null, null, null
)
songGenreCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres.Members._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
songs.find { it.id == id }?.let {
genre.addSong(it)
}
}
}
}
logD("Song search finished with ${songs.size} found")
}
enum class Response {
SUCCESS, FAILED, NO_MUSIC
}
}

View file

@ -3,160 +3,34 @@ package org.oxycblt.auxio.music.processing
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
/**
* The sorter object for music loading.
*/
class MusicSorter(
var genres: MutableList<Genre>,
val artists: MutableList<Artist>,
val albums: MutableList<Album>,
val songs: MutableList<Song>,
private val genrePlaceholder: String,
private val artistPlaceholder: String,
private val albumPlaceholder: String,
val albums: MutableList<Album>
) {
init {
sortSongsIntoAlbums()
sortAlbumsIntoArtists()
sortArtistsIntoGenres()
fixBuggyGenres()
}
val artists = mutableListOf<Artist>()
private fun sortSongsIntoAlbums() {
logD("Sorting songs into albums...")
val unknownSongs = songs.toMutableList()
for (album in albums) {
// Find all songs that match the current album ID to prevent any bugs w/comparing names.
// This cant be done anywhere else sadly. Blame the genre system.
val albumSongs = songs.filter { it.albumId == album.id }
// Then add them to the album
for (song in albumSongs) {
song.album = album
album.songs.add(song)
}
unknownSongs.removeAll(albumSongs)
fun sort() {
albums.forEach {
groupSongsIntoAlbum(it)
}
// Any remaining songs will be added to an unknown album
if (unknownSongs.size > 0) {
createArtistsFromAlbums(albums)
}
val unknownAlbum = Album(
name = albumPlaceholder
private fun groupSongsIntoAlbum(album: Album) {
album.applySongs(songs.filter { it.albumId == album.id })
}
private fun createArtistsFromAlbums(albums: List<Album>) {
val groupedAlbums = albums.groupBy { it.artistName }
groupedAlbums.forEach {
logD(it.key)
artists.add(
Artist(id = artists.size.toLong(), name = it.key, albums = it.value)
)
for (song in unknownSongs) {
song.album = unknownAlbum
unknownAlbum.songs.add(song)
}
albums.add(unknownAlbum)
logD("${unknownSongs.size} songs were placed into an unknown album.")
}
}
private fun sortAlbumsIntoArtists() {
logD("Sorting albums into artists...")
val unknownAlbums = albums.toMutableList()
for (artist in artists) {
// Find all albums that match the current artist name
val artistAlbums = albums.filter { it.artistId == artist.id }
// Then add them to the artist, along with refreshing the amount of albums
for (album in artistAlbums) {
album.artist = artist
artist.albums.add(album)
}
// Then group the artist's genres and sort them by "Prominence"
// A.K.A Who has the most bugged duplicate genres
val groupedGenres = artist.genres.groupBy { it.name }
artist.genres.clear()
groupedGenres.keys.sortedByDescending { key ->
groupedGenres[key]?.size
}.forEach { key ->
groupedGenres[key]?.get(0)?.let {
artist.genres.add(it)
}
}
unknownAlbums.removeAll(artistAlbums)
}
// Any remaining albums will be added to an unknown artist
if (unknownAlbums.size > 0) {
// Reuse an existing unknown artist if one is found
val unknownArtist = Artist(
name = artistPlaceholder
)
for (album in unknownAlbums) {
album.artist = unknownArtist
unknownArtist.albums.add(album)
}
artists.add(unknownArtist)
logD("${unknownAlbums.size} albums were placed into an unknown artist.")
}
}
private fun sortArtistsIntoGenres() {
logD("Sorting artists into genres...")
val unknownArtists = artists.toMutableList()
for (genre in genres) {
// Find all artists that match the current genre
val genreArtists = artists.filter { artist ->
artist.genres.any {
it.name == genre.name
}
}
// Then add them to the genre, along with refreshing the amount of artists
genre.artists.addAll(genreArtists)
unknownArtists.removeAll(genreArtists)
}
if (unknownArtists.size > 0) {
// Reuse an existing unknown genre if one is found
val unknownGenre = genres.find { it.name == genrePlaceholder } ?: Genre(
name = genrePlaceholder
)
for (artist in unknownArtists) {
artist.genres.add(unknownGenre)
unknownGenre.artists.add(artist)
}
genres.add(unknownGenre)
logD("${unknownArtists.size} artists were placed into an unknown genre.")
}
}
// Band-aid any buggy genres created by the broken Music Loading system.
private fun fixBuggyGenres() {
// Remove genre duplicates at the end, as duplicate genres can be added during
// the sorting process as well.
genres = genres.distinctBy {
it.name
}.toMutableList()
// Also eliminate any genres that don't have artists, which also happens sometimes.
genres.removeAll { it.artists.isEmpty() }
}
}

View file

@ -15,6 +15,7 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.SortMode
import org.oxycblt.auxio.settings.SettingsManager
import kotlin.random.Random
@ -143,14 +144,6 @@ class PlaybackStateManager private constructor() {
* @param mode The [PlaybackMode] to construct the queue off of.
*/
fun playSong(song: Song, mode: PlaybackMode) {
// Auxio doesn't support playing songs while swapping the mode to GENRE, as its impossible
// to determine what genre a song has.
if (mode == PlaybackMode.IN_GENRE) {
logE("Auxio cant play songs with the mode of IN_GENRE.")
return
}
logD("Updating song to ${song.name} and mode to $mode")
val musicStore = MusicStore.getInstance()
@ -161,17 +154,19 @@ class PlaybackStateManager private constructor() {
mQueue = musicStore.songs.toMutableList()
}
PlaybackMode.IN_GENRE -> {
mParent = song.genre
mQueue = orderSongsInGenre(song.genre!!)
}
PlaybackMode.IN_ARTIST -> {
mParent = song.album.artist
mQueue = song.album.artist.songs.toMutableList()
mQueue = orderSongsInArtist(song.album.artist)
}
PlaybackMode.IN_ALBUM -> {
mParent = song.album
mQueue = song.album.songs
}
else -> {
mQueue = orderSongsInAlbum(song.album)
}
}
@ -780,54 +775,12 @@ class PlaybackStateManager private constructor() {
mParent = when (mMode) {
PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album
PlaybackMode.IN_ARTIST -> mQueue.firstOrNull()?.album?.artist
PlaybackMode.IN_GENRE -> getCommonGenre()
PlaybackMode.IN_GENRE -> mQueue.firstOrNull()?.genre
PlaybackMode.ALL_SONGS -> null
}
}
}
/**
* Search for the common genre out of a queue of songs that **should have a common genre**.
* @return The **single** common genre, null if there isn't any or if there's multiple.
*/
private fun getCommonGenre(): Genre? {
// Pool of "Possible" genres, these get narrowed down until the list is only
// the actual genre(s) that all songs in the queue have in common.
var genres = mutableListOf<Genre>()
var otherGenres: MutableList<Genre>
for (queueSong in mQueue) {
// If there's still songs to check despite the pool of genres being empty, re-add them.
if (genres.size == 0) {
genres.addAll(queueSong.album.artist.genres)
continue
}
otherGenres = genres.toMutableList()
// Iterate through the current genres and remove the ones that don't exist in this song,
// narrowing down the pool of possible genres.
for (genre in genres) {
if (queueSong.album.artist.genres.find { it.id == genre.id } == null) {
otherGenres.remove(genre)
}
}
genres = otherGenres.toMutableList()
}
logD("Found genre $genres")
// There should not be more than one common genre, so return null if that's the case
if (genres.size > 1) {
return null
}
// Sometimes the narrowing process will lead to a zero-size list, so return null if that
// is the case.
return genres.firstOrNull()
}
// --- ORDERING FUNCTIONS ---
/**
@ -866,17 +819,7 @@ class PlaybackStateManager private constructor() {
* Create an ordered queue based on a [Genre].
*/
private fun orderSongsInGenre(genre: Genre): MutableList<Song> {
val final = mutableListOf<Song>()
genre.artists.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }
).forEach { artist ->
artist.albums.sortedByDescending { it.year }.forEach { album ->
final.addAll(album.songs.sortedBy { it.track })
}
}
return final
return SortMode.ALPHA_DOWN.getSortedSongList(genre.songs).toMutableList()
}
/**

View file

@ -54,7 +54,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_album" />
<androidx.appcompat.widget.AppCompatTextView
<TextView
android:id="@+id/album_name"
style="@style/DetailTitleText"
android:layout_width="0dp"

View file

@ -52,7 +52,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_artist" />
<androidx.appcompat.widget.AppCompatTextView
<TextView
android:id="@+id/artist_name"
style="@style/DetailTitleText"
android:layout_width="0dp"

View file

@ -53,7 +53,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_genre" />
<androidx.appcompat.widget.AppCompatTextView
<TextView
android:id="@+id/genre_name"
style="@style/DetailTitleText"
android:layout_width="0dp"
@ -90,16 +90,16 @@
android:text="@{@plurals/format_song_count(genre.songs.size, genre.songs.size)}"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/genre_artist_header"
app:layout_constraintBottom_toTopOf="@+id/genre_song_header"
app:layout_constraintStart_toEndOf="@+id/genre_image"
app:layout_constraintTop_toBottomOf="@+id/genre_counts"
tools:text="80 Songs" />
<TextView
android:id="@+id/genre_artist_header"
android:id="@+id/genre_song_header"
style="@style/HeaderText"
android:layout_marginTop="@dimen/margin_medium"
android:text="@string/label_artists"
android:text="@string/label_songs"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/genre_image" />
@ -108,13 +108,13 @@
style="@style/HeaderAction"
android:contentDescription="@string/description_sort_button"
android:onClick="@{() -> detailModel.incrementGenreSortMode()}"
app:layout_constraintBottom_toBottomOf="@+id/genre_artist_header"
app:layout_constraintBottom_toBottomOf="@+id/genre_song_header"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/genre_artist_header"
app:layout_constraintTop_toTopOf="@+id/genre_song_header"
tools:src="@drawable/ic_sort_alpha_down" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/genre_artist_recycler"
android:id="@+id/genre_song_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -123,10 +123,10 @@
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/genre_artist_header"
app:layout_constraintTop_toBottomOf="@+id/genre_song_header"
app:spanCount="2"
tools:itemCount="4"
tools:listitem="@layout/item_artist" />
tools:listitem="@layout/item_song" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -54,7 +54,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_album" />
<androidx.appcompat.widget.AppCompatTextView
<TextView
android:id="@+id/album_name"
style="@style/DetailTitleText"
android:layout_width="0dp"

View file

@ -53,7 +53,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_artist" />
<androidx.appcompat.widget.AppCompatTextView
<TextView
android:id="@+id/artist_name"
style="@style/DetailTitleText"
android:layout_width="0dp"

View file

@ -54,6 +54,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_genre" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/genre_name"
style="@style/DetailTitleText"
@ -94,10 +95,10 @@
tools:text="80 Songs" />
<TextView
android:id="@+id/genre_artist_header"
android:id="@+id/genre_song_header"
style="@style/HeaderText"
android:layout_marginTop="@dimen/padding_medium"
android:text="@string/label_artists"
android:text="@string/label_songs"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/genre_song_count" />
@ -106,13 +107,13 @@
style="@style/HeaderAction"
android:contentDescription="@string/description_sort_button"
android:onClick="@{() -> detailModel.incrementGenreSortMode()}"
app:layout_constraintBottom_toBottomOf="@+id/genre_artist_header"
app:layout_constraintBottom_toBottomOf="@+id/genre_song_header"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/genre_artist_header"
app:layout_constraintTop_toTopOf="@+id/genre_song_header"
tools:src="@drawable/ic_sort_alpha_down" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/genre_artist_recycler"
android:id="@+id/genre_song_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -121,9 +122,9 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/genre_artist_header"
app:layout_constraintTop_toBottomOf="@+id/genre_song_header"
tools:itemCount="4"
tools:listitem="@layout/item_artist" />
tools:listitem="@layout/item_song" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -38,12 +38,12 @@
<TextView
android:id="@+id/genre_count"
style="@style/ItemText.Secondary"
app:genreCounts="@{genre}"
app:altGenreCounts="@{genre}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/genre_image"
app:layout_constraintTop_toBottomOf="@+id/genre_name"
tools:text="2 Artists, 4 Albums" />
tools:text="4 Albums, 40 Songs" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>