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 * instead of out of the app if a Detail Fragment is currently open. Also carries the
* multi-navigation fix. * multi-navigation fix.
* TODO: Migrate to a more powerful/efficient CoordinatorLayout instead of NestedScrollView * TODO: Migrate to a more powerful/efficient CoordinatorLayout instead of NestedScrollView
* TODO: Add custom artist images
* @author OxygenCobalt * @author OxygenCobalt
*/ */
abstract class DetailFragment : Fragment() { abstract class DetailFragment : Fragment() {

View file

@ -10,12 +10,13 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentGenreDetailBinding 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.logD
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.disable import org.oxycblt.auxio.ui.disable
import org.oxycblt.auxio.ui.setupArtistActions import org.oxycblt.auxio.ui.setupSongActions
/** /**
* The [DetailFragment] for a genre. * The [DetailFragment] for a genre.
@ -45,19 +46,13 @@ class GenreDetailFragment : DetailFragment() {
) )
} }
val artistAdapter = GenreArtistAdapter( val songAdapter = GenreSongAdapter(
doOnClick = { doOnClick = {
if (!detailModel.isNavigating) { playbackModel.playSong(it, PlaybackMode.IN_GENRE)
detailModel.updateNavigationStatus(true)
findNavController().navigate(
GenreDetailFragmentDirections.actionShowArtist(it.id)
)
}
}, },
doOnLongClick = { data, view -> doOnLongClick = { data, view ->
PopupMenu(requireContext(), view).setupArtistActions( PopupMenu(requireContext(), view).setupSongActions(
data, playbackModel 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!!.songs.size < 2) {
if (detailModel.currentGenre.value!!.artists.size < 2) {
binding.genreSortButton.disable(requireContext()) binding.genreSortButton.disable(requireContext())
} }
binding.genreArtistRecycler.apply { binding.genreSongRecycler.apply {
adapter = artistAdapter adapter = songAdapter
setHasFixedSize(true) setHasFixedSize(true)
} }
@ -115,8 +109,8 @@ class GenreDetailFragment : DetailFragment() {
binding.genreSortButton.setImageResource(mode.iconRes) binding.genreSortButton.setImageResource(mode.iconRes)
// Then update the sort mode of the artist adapter. // Then update the sort mode of the artist adapter.
artistAdapter.submitList( songAdapter.submitList(
mode.getSortedArtistList(detailModel.currentGenre.value!!.artists) 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.databinding.FragmentLoadingBinding
import org.oxycblt.auxio.logD import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.MusicStore 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 * 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 --- // --- VIEWMODEL SETUP ---
loadingModel.response.observe(viewLifecycleOwner) { loadingModel.response.observe(viewLifecycleOwner) {
if (it == MusicLoaderResponse.DONE) { if (it == MusicLoader.Response.SUCCESS) {
findNavController().navigate( findNavController().navigate(
LoadingFragmentDirections.actionToMain() 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 // 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 // depending on which error response was given, along with a retry button
binding.loadingErrorText.text = binding.loadingErrorText.text =
if (it == MusicLoaderResponse.NO_MUSIC) if (it == MusicLoader.Response.NO_MUSIC)
getString(R.string.error_no_music) getString(R.string.error_no_music)
else else
getString(R.string.error_music_load_failed) getString(R.string.error_music_load_failed)

View file

@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.MusicStore 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 * 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 * @author OxygenCobalt
*/ */
class LoadingViewModel(private val app: Application) : ViewModel() { class LoadingViewModel(private val app: Application) : ViewModel() {
private val mResponse = MutableLiveData<MusicLoaderResponse>() private val mResponse = MutableLiveData<MusicLoader.Response>()
val response: LiveData<MusicLoaderResponse> get() = mResponse val response: LiveData<MusicLoader.Response> get() = mResponse
private val mRedo = MutableLiveData<Boolean>() private val mRedo = MutableLiveData<Boolean>()
val doReload: LiveData<Boolean> get() = mRedo val doReload: LiveData<Boolean> get() = mRedo

View file

@ -21,6 +21,7 @@ sealed class BaseModel {
* @property track The Song's Track number * @property track The Song's Track number
* @property duration The duration of the song, in millis. * @property duration The duration of the song, in millis.
* @property album The Song's parent album. Use this instead of [albumId]. * @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 seconds The Song's duration in seconds
* @property formattedDuration The Song's duration as a duration string. * @property formattedDuration The Song's duration as a duration string.
* @author OxygenCobalt * @author OxygenCobalt
@ -32,7 +33,31 @@ data class Song(
val track: Int = -1, val track: Int = -1,
val duration: Long = 0, val duration: Long = 0,
) : BaseModel() { ) : 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 seconds = duration / 1000
val formattedDuration: String = seconds.toDuration() val formattedDuration: String = seconds.toDuration()
@ -40,24 +65,35 @@ data class Song(
/** /**
* The data object for an album. Inherits [BaseModel]. * 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 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 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 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 artist The Album's parent [Artist]. use this instead of [artistName]
* @property songs The Album's child [Song]s. * @property songs The Album's child [Song]s.
* @property totalDuration The combined duration of all of the album's child songs, formatted. * @property totalDuration The combined duration of all of the album's child songs, formatted.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
data class Album( data class Album(
override val id: Long = -1, override val id: Long = -1,
override val name: String, override val name: String,
val artistId: Long = -1, val artistName: String,
val coverUri: Uri = Uri.EMPTY, val coverUri: Uri = Uri.EMPTY,
val year: Int = 0 val year: Int = 0
) : BaseModel() { ) : 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 { val totalDuration: String by lazy {
var seconds: Long = 0 var seconds: Long = 0
songs.forEach { songs.forEach {
@ -65,21 +101,44 @@ data class Album(
} }
seconds.toDuration() 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] * The data object for an artist. Inherits [BaseModel]
* @property albums The list of all [Album]s in this artist * @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 * @property songs The list of all [Song]s in this artist
* @author OxygenCobalt * @author OxygenCobalt
*/ */
data class Artist( data class Artist(
override val id: Long = -1, override val id: Long = -1,
override var name: String override var name: String,
val albums: List<Album>
) : BaseModel() { ) : BaseModel() {
val albums = mutableListOf<Album>() init {
val genres = mutableListOf<Genre>() 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: List<Song> by lazy {
val songs = mutableListOf<Song>() val songs = mutableListOf<Song>()
@ -92,8 +151,6 @@ data class Artist(
/** /**
* The data object for a genre. Inherits [BaseModel] * 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. * @property songs The list of all [Song]s in this genre.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@ -101,21 +158,20 @@ data class Genre(
override val id: Long = -1, override val id: Long = -1,
override var name: String, override var name: String,
) : BaseModel() { ) : BaseModel() {
val artists = mutableListOf<Artist>() private val mSongs = mutableListOf<Song>()
val songs: List<Song> get() = mSongs
val albums: List<Album> by lazy { val albumCount: Int by lazy {
val albums = mutableListOf<Album>() songs.groupBy { it.album }.size
artists.forEach {
albums.addAll(it.albums)
}
albums
} }
val songs: List<Song> by lazy {
val songs = mutableListOf<Song>() val artistCount: Int by lazy {
artists.forEach { songs.groupBy { it.album.artist }.size
songs.addAll(it.songs) }
}
songs 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 android.app.Application
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.R
import org.oxycblt.auxio.logD import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.processing.MusicLoader 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.music.processing.MusicSorter
import org.oxycblt.auxio.recycler.DisplayMode import org.oxycblt.auxio.recycler.DisplayMode
/** /**
* The main storage for music items. Use [MusicStore.getInstance] to get the single instance of it. * The main storage for music items. Use [MusicStore.getInstance] to get the single instance of it.
* TODO: Completely rewrite this system.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MusicStore private constructor() { class MusicStore private constructor() {
@ -45,43 +42,25 @@ class MusicStore private constructor() {
* ***THIS SHOULD ONLY BE RAN FROM AN IO THREAD.*** * ***THIS SHOULD ONLY BE RAN FROM AN IO THREAD.***
* @param app [Application] required to load the music. * @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) { return withContext(Dispatchers.IO) {
this@MusicStore.logD("Starting initial music load...") this@MusicStore.logD("Starting initial music load...")
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
// Get the placeholder strings, which are used by MusicLoader & MusicSorter for val loader = MusicLoader(app)
// any music that doesn't have metadata. val response = loader.loadMusic()
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( if (response == MusicLoader.Response.SUCCESS) {
app.contentResolver,
genrePlaceholder,
artistPlaceholder,
albumPlaceholder
)
if (loader.response == MusicLoaderResponse.DONE) {
// If the loading succeeds, then sort the songs and update the value // If the loading succeeds, then sort the songs and update the value
val sorter = MusicSorter( val sorter = MusicSorter(loader.songs, loader.albums)
loader.genres,
loader.artists,
loader.albums,
loader.songs,
genrePlaceholder, sorter.sort()
artistPlaceholder,
albumPlaceholder
)
mSongs = sorter.songs.toList() mSongs = sorter.songs.toList()
mAlbums = sorter.albums.toList() mAlbums = sorter.albums.toList()
mArtists = sorter.artists.toList() mArtists = sorter.artists.toList()
mGenres = sorter.genres.toList() mGenres = loader.genres.toList()
val elapsed = System.currentTimeMillis() - start val elapsed = System.currentTimeMillis() - start
@ -90,7 +69,7 @@ class MusicStore private constructor() {
loaded = true loaded = true
} }
loader.response response
} }
} }

View file

@ -111,26 +111,37 @@ fun Int.toYear(context: Context): String {
@BindingAdapter("genreCounts") @BindingAdapter("genreCounts")
fun TextView.bindGenreCounts(genre: Genre) { fun TextView.bindGenreCounts(genre: Genre) {
val artists = context.resources.getQuantityString( 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( 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) 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 * Bind the most prominent artist genre
*/ */
// TODO: Add option to list all genres
@BindingAdapter("artistGenre") @BindingAdapter("artistGenre")
fun TextView.bindArtistGenre(artist: Artist) { fun TextView.bindArtistGenre(artist: Artist) {
text = if (artist.genres.isNotEmpty()) { text = artist.genre?.name ?: context.getString(R.string.placeholder_genre)
artist.genres[0].name
} else {
context.getString(R.string.placeholder_genre)
}
} }
/** /**

View file

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

View file

@ -1,69 +1,47 @@
package org.oxycblt.auxio.music.processing package org.oxycblt.auxio.music.processing
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentResolver import android.app.Application
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Albums import android.provider.MediaStore.Audio.Albums
import android.provider.MediaStore.Audio.Artists
import android.provider.MediaStore.Audio.Genres import android.provider.MediaStore.Audio.Genres
import android.provider.MediaStore.Audio.Media 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.logD
import org.oxycblt.auxio.logE import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toAlbumArtURI import org.oxycblt.auxio.music.toAlbumArtURI
import org.oxycblt.auxio.music.toNamedGenre 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. * 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( class MusicLoader(private val app: Application) {
private val resolver: ContentResolver,
private val genrePlaceholder: String,
private val artistPlaceholder: String,
private val albumPlaceholder: String,
) {
var genres = mutableListOf<Genre>() var genres = mutableListOf<Genre>()
var artists = mutableListOf<Artist>()
var albums = mutableListOf<Album>() var albums = mutableListOf<Album>()
var songs = mutableListOf<Song>() var songs = mutableListOf<Song>()
val response: MusicLoaderResponse private val resolver = app.contentResolver
init { fun loadMusic(): Response {
response = findMusic()
}
private fun findMusic(): MusicLoaderResponse {
try { try {
val start = System.currentTimeMillis()
loadGenres() loadGenres()
loadArtists()
loadAlbums() loadAlbums()
loadSongs() loadSongs()
logD("Done in ${System.currentTimeMillis() - start}ms")
} catch (error: Exception) { } catch (error: Exception) {
logE("Something went horribly wrong.") logE("Something went horribly wrong.")
error.printStackTrace() error.printStackTrace()
return MusicLoaderResponse.FAILURE return Response.FAILED
} }
if (songs.size == 0) { if (songs.size == 0) {
return MusicLoaderResponse.NO_MUSIC return Response.NO_MUSIC
} }
return MusicLoaderResponse.DONE return Response.SUCCESS
} }
private fun loadGenres() { private fun loadGenres() {
@ -80,6 +58,8 @@ class MusicLoader(
Genres.DEFAULT_SORT_ORDER Genres.DEFAULT_SORT_ORDER
) )
val genrePlaceholder = app.getString(R.string.placeholder_genre)
// And then process those into Genre objects // And then process those into Genre objects
genreCursor?.use { cursor -> genreCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Genres._ID) val idIndex = cursor.getColumnIndexOrThrow(Genres._ID)
@ -87,9 +67,9 @@ class MusicLoader(
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex) 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. // convert that to the corresponding ID3 genre.
if (name.contains(Regex("[0123456789)]"))) { if (name.contains(Regex("[0123456789)]"))) {
name = name.toNamedGenre() ?: genrePlaceholder name = name.toNamedGenre() ?: genrePlaceholder
@ -108,73 +88,6 @@ class MusicLoader(
logD("Genre search finished with ${genres.size} genres found.") 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") @SuppressLint("InlinedApi")
private fun loadAlbums() { private fun loadAlbums() {
logD("Starting album search...") logD("Starting album search...")
@ -184,7 +97,7 @@ class MusicLoader(
arrayOf( arrayOf(
Albums._ID, // 0 Albums._ID, // 0
Albums.ALBUM, // 1 Albums.ALBUM, // 1
Albums.ARTIST_ID, // 2 Albums.ARTIST, // 2
Albums.FIRST_YEAR, // 3 Albums.FIRST_YEAR, // 3
), ),
@ -192,24 +105,26 @@ class MusicLoader(
Albums.DEFAULT_SORT_ORDER Albums.DEFAULT_SORT_ORDER
) )
val albumPlaceholder = app.getString(R.string.placeholder_album)
val artistPlaceholder = app.getString(R.string.placeholder_artist)
albumCursor?.use { cursor -> albumCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Albums._ID) val idIndex = cursor.getColumnIndexOrThrow(Albums._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Albums.ALBUM) 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) val yearIndex = cursor.getColumnIndexOrThrow(Albums.FIRST_YEAR)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val name = cursor.getString(nameIndex) ?: albumPlaceholder val name = cursor.getString(nameIndex) ?: albumPlaceholder
val artistId = cursor.getLong(artistIdIndex) val artistName = cursor.getString(artistIdIndex) ?: artistPlaceholder
val year = cursor.getInt(yearIndex) val year = cursor.getInt(yearIndex)
val coverUri = id.toAlbumArtURI() val coverUri = id.toAlbumArtURI()
albums.add( albums.add(
Album( Album(
id, name, artistId, id = id, name = name, artistName = artistName,
coverUri, year coverUri = coverUri, year = year
) )
) )
} }
@ -218,7 +133,7 @@ class MusicLoader(
} }
albums = albums.distinctBy { albums = albums.distinctBy {
it.name to it.artistId to it.year it.name to it.artistName to it.year
}.toMutableList() }.toMutableList()
logD("Album search finished with ${albums.size} albums found") 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 it.name to it.albumId to it.track to it.duration
}.toMutableList() }.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") 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.logD
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
/**
* The sorter object for music loading.
*/
class MusicSorter( class MusicSorter(
var genres: MutableList<Genre>,
val artists: MutableList<Artist>,
val albums: MutableList<Album>,
val songs: MutableList<Song>, val songs: MutableList<Song>,
val albums: MutableList<Album>
private val genrePlaceholder: String,
private val artistPlaceholder: String,
private val albumPlaceholder: String,
) { ) {
init { val artists = mutableListOf<Artist>()
sortSongsIntoAlbums()
sortAlbumsIntoArtists()
sortArtistsIntoGenres()
fixBuggyGenres()
}
private fun sortSongsIntoAlbums() { fun sort() {
logD("Sorting songs into albums...") albums.forEach {
groupSongsIntoAlbum(it)
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)
} }
// Any remaining songs will be added to an unknown album createArtistsFromAlbums(albums)
if (unknownSongs.size > 0) { }
val unknownAlbum = Album( private fun groupSongsIntoAlbum(album: Album) {
name = albumPlaceholder 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.Header
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.recycler.SortMode
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import kotlin.random.Random import kotlin.random.Random
@ -143,14 +144,6 @@ class PlaybackStateManager private constructor() {
* @param mode The [PlaybackMode] to construct the queue off of. * @param mode The [PlaybackMode] to construct the queue off of.
*/ */
fun playSong(song: Song, mode: PlaybackMode) { 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") logD("Updating song to ${song.name} and mode to $mode")
val musicStore = MusicStore.getInstance() val musicStore = MusicStore.getInstance()
@ -161,17 +154,19 @@ class PlaybackStateManager private constructor() {
mQueue = musicStore.songs.toMutableList() mQueue = musicStore.songs.toMutableList()
} }
PlaybackMode.IN_GENRE -> {
mParent = song.genre
mQueue = orderSongsInGenre(song.genre!!)
}
PlaybackMode.IN_ARTIST -> { PlaybackMode.IN_ARTIST -> {
mParent = song.album.artist mParent = song.album.artist
mQueue = song.album.artist.songs.toMutableList() mQueue = orderSongsInArtist(song.album.artist)
} }
PlaybackMode.IN_ALBUM -> { PlaybackMode.IN_ALBUM -> {
mParent = song.album mParent = song.album
mQueue = song.album.songs mQueue = orderSongsInAlbum(song.album)
}
else -> {
} }
} }
@ -780,54 +775,12 @@ class PlaybackStateManager private constructor() {
mParent = when (mMode) { mParent = when (mMode) {
PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album PlaybackMode.IN_ALBUM -> mQueue.firstOrNull()?.album
PlaybackMode.IN_ARTIST -> mQueue.firstOrNull()?.album?.artist PlaybackMode.IN_ARTIST -> mQueue.firstOrNull()?.album?.artist
PlaybackMode.IN_GENRE -> getCommonGenre() PlaybackMode.IN_GENRE -> mQueue.firstOrNull()?.genre
PlaybackMode.ALL_SONGS -> null 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 --- // --- ORDERING FUNCTIONS ---
/** /**
@ -866,17 +819,7 @@ class PlaybackStateManager private constructor() {
* Create an ordered queue based on a [Genre]. * Create an ordered queue based on a [Genre].
*/ */
private fun orderSongsInGenre(genre: Genre): MutableList<Song> { private fun orderSongsInGenre(genre: Genre): MutableList<Song> {
val final = mutableListOf<Song>() return SortMode.ALPHA_DOWN.getSortedSongList(genre.songs).toMutableList()
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
} }
/** /**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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