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:
parent
dc26d70088
commit
6e5bff3bd3
20 changed files with 264 additions and 488 deletions
|
@ -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() {
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue