music: refactor model usage
Refactor the way music models are constructed to achieve the following: - Add a unified interface for resolving display names of artists - Disambiguate the role of Header in the music objects - Eliminate the need to load strings in with a context when creating Header instances
This commit is contained in:
parent
51ba72d861
commit
df49e2765f
45 changed files with 315 additions and 245 deletions
|
@ -71,7 +71,10 @@ fun ImageView.bindGenreImage(genre: Genre?) {
|
|||
load(genre, R.drawable.ic_genre, MosaicFetcher(context))
|
||||
|
||||
if (genre != null) {
|
||||
contentDescription = context.getString(R.string.desc_genre_image, genre.name)
|
||||
contentDescription = context.getString(
|
||||
R.string.desc_genre_image,
|
||||
genre.resolvedName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ class AlbumDetailFragment : DetailFragment() {
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
detailModel.setAlbum(args.albumId, requireContext())
|
||||
detailModel.setAlbum(args.albumId)
|
||||
|
||||
val detailAdapter = AlbumDetailAdapter(
|
||||
playbackModel, detailModel,
|
||||
|
|
|
@ -48,7 +48,7 @@ class ArtistDetailFragment : DetailFragment() {
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
detailModel.setArtist(args.artistId, requireContext())
|
||||
detailModel.setArtist(args.artistId)
|
||||
|
||||
val detailAdapter = ArtistDetailAdapter(
|
||||
playbackModel,
|
||||
|
|
|
@ -74,7 +74,7 @@ abstract class DetailFragment : Fragment() {
|
|||
super.onStop()
|
||||
|
||||
// Cancel all pending menus when this fragment stops to prevent bugs/crashes
|
||||
detailModel.finishShowMenu(null, requireContext())
|
||||
detailModel.finishShowMenu(null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -138,12 +138,12 @@ abstract class DetailFragment : Fragment() {
|
|||
|
||||
setOnMenuItemClickListener { item ->
|
||||
item.isChecked = true
|
||||
detailModel.finishShowMenu(SortMode.fromId(item.itemId)!!, config.anchor.context)
|
||||
detailModel.finishShowMenu(SortMode.fromId(item.itemId)!!)
|
||||
true
|
||||
}
|
||||
|
||||
setOnDismissListener {
|
||||
detailModel.finishShowMenu(null, config.anchor.context)
|
||||
detailModel.finishShowMenu(null)
|
||||
}
|
||||
|
||||
if (showItem != null) {
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
@ -30,6 +29,7 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Header
|
||||
import org.oxycblt.auxio.music.HeaderString
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
|
@ -80,50 +80,50 @@ class DetailViewModel : ViewModel() {
|
|||
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
fun setGenre(id: Long, context: Context) {
|
||||
fun setGenre(id: Long) {
|
||||
if (mCurGenre.value?.id == id) return
|
||||
|
||||
val musicStore = MusicStore.requireInstance()
|
||||
mCurGenre.value = musicStore.genres.find { it.id == id }
|
||||
refreshGenreData(context)
|
||||
refreshGenreData()
|
||||
}
|
||||
|
||||
fun setArtist(id: Long, context: Context) {
|
||||
fun setArtist(id: Long) {
|
||||
if (mCurArtist.value?.id == id) return
|
||||
|
||||
val musicStore = MusicStore.requireInstance()
|
||||
mCurArtist.value = musicStore.artists.find { it.id == id }
|
||||
refreshArtistData(context)
|
||||
refreshArtistData()
|
||||
}
|
||||
|
||||
fun setAlbum(id: Long, context: Context) {
|
||||
fun setAlbum(id: Long) {
|
||||
if (mCurAlbum.value?.id == id) return
|
||||
|
||||
val musicStore = MusicStore.requireInstance()
|
||||
mCurAlbum.value = musicStore.albums.find { it.id == id }
|
||||
refreshAlbumData(context)
|
||||
refreshAlbumData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that the menu process is done with the new [SortMode].
|
||||
* Pass null if there was no change.
|
||||
*/
|
||||
fun finishShowMenu(newMode: SortMode?, context: Context) {
|
||||
fun finishShowMenu(newMode: SortMode?) {
|
||||
mShowMenu.value = null
|
||||
|
||||
if (newMode != null) {
|
||||
when (currentMenuContext) {
|
||||
DisplayMode.SHOW_ALBUMS -> {
|
||||
settingsManager.detailAlbumSort = newMode
|
||||
refreshAlbumData(context)
|
||||
refreshAlbumData()
|
||||
}
|
||||
DisplayMode.SHOW_ARTISTS -> {
|
||||
settingsManager.detailArtistSort = newMode
|
||||
refreshArtistData(context)
|
||||
refreshArtistData()
|
||||
}
|
||||
DisplayMode.SHOW_GENRES -> {
|
||||
settingsManager.detailGenreSort = newMode
|
||||
refreshGenreData(context)
|
||||
refreshGenreData()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
@ -153,13 +153,13 @@ class DetailViewModel : ViewModel() {
|
|||
isNavigating = navigating
|
||||
}
|
||||
|
||||
private fun refreshGenreData(context: Context) {
|
||||
private fun refreshGenreData() {
|
||||
val data = mutableListOf<BaseModel>(curGenre.value!!)
|
||||
|
||||
data.add(
|
||||
ActionHeader(
|
||||
id = -2,
|
||||
name = context.getString(R.string.lbl_songs),
|
||||
string = HeaderString.Single(R.string.lbl_songs),
|
||||
icon = R.drawable.ic_sort,
|
||||
desc = R.string.lbl_sort,
|
||||
onClick = { view ->
|
||||
|
@ -174,14 +174,14 @@ class DetailViewModel : ViewModel() {
|
|||
mGenreData.value = data
|
||||
}
|
||||
|
||||
private fun refreshArtistData(context: Context) {
|
||||
private fun refreshArtistData() {
|
||||
val artist = curArtist.value!!
|
||||
val data = mutableListOf<BaseModel>(artist)
|
||||
|
||||
data.add(
|
||||
Header(
|
||||
id = -2,
|
||||
name = context.getString(R.string.lbl_albums)
|
||||
string = HeaderString.Single(R.string.lbl_albums)
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -190,7 +190,7 @@ class DetailViewModel : ViewModel() {
|
|||
data.add(
|
||||
ActionHeader(
|
||||
id = -3,
|
||||
name = context.getString(R.string.lbl_songs),
|
||||
string = HeaderString.Single(R.string.lbl_songs),
|
||||
icon = R.drawable.ic_sort,
|
||||
desc = R.string.lbl_sort,
|
||||
onClick = { view ->
|
||||
|
@ -205,13 +205,13 @@ class DetailViewModel : ViewModel() {
|
|||
mArtistData.value = data.toList()
|
||||
}
|
||||
|
||||
private fun refreshAlbumData(context: Context) {
|
||||
private fun refreshAlbumData() {
|
||||
val data = mutableListOf<BaseModel>(curAlbum.value!!)
|
||||
|
||||
data.add(
|
||||
ActionHeader(
|
||||
id = -2,
|
||||
name = context.getString(R.string.lbl_songs),
|
||||
string = HeaderString.Single(R.string.lbl_songs),
|
||||
icon = R.drawable.ic_sort,
|
||||
desc = R.string.lbl_sort,
|
||||
onClick = { view ->
|
||||
|
|
|
@ -48,7 +48,7 @@ class GenreDetailFragment : DetailFragment() {
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
detailModel.setGenre(args.genreId, requireContext())
|
||||
detailModel.setGenre(args.genreId)
|
||||
|
||||
val detailAdapter = GenreDetailAdapter(
|
||||
playbackModel,
|
||||
|
|
|
@ -145,7 +145,7 @@ class AlbumDetailAdapter(
|
|||
binding.detailName.text = data.name
|
||||
|
||||
binding.detailSubhead.apply {
|
||||
text = data.artist.name
|
||||
text = data.artist.resolvedName
|
||||
|
||||
setOnClickListener {
|
||||
detailModel.navToItem(data.artist)
|
||||
|
|
|
@ -190,10 +190,13 @@ class ArtistDetailAdapter(
|
|||
|
||||
binding.detailCover.apply {
|
||||
bindArtistImage(data)
|
||||
contentDescription = context.getString(R.string.desc_artist_image, data.name)
|
||||
contentDescription = context.getString(
|
||||
R.string.desc_artist_image,
|
||||
data.resolvedName
|
||||
)
|
||||
}
|
||||
|
||||
binding.detailName.text = data.name
|
||||
binding.detailName.text = data.resolvedName
|
||||
|
||||
binding.detailSubhead.text = data.genre?.resolvedName
|
||||
?: context.getString(R.string.def_genre)
|
||||
|
|
|
@ -136,10 +136,13 @@ class GenreDetailAdapter(
|
|||
|
||||
binding.detailCover.apply {
|
||||
bindGenreImage(data)
|
||||
contentDescription = context.getString(R.string.desc_artist_image, data.name)
|
||||
contentDescription = context.getString(
|
||||
R.string.desc_genre_image,
|
||||
data.resolvedName
|
||||
)
|
||||
}
|
||||
|
||||
binding.detailName.text = data.name
|
||||
binding.detailName.text = data.resolvedName
|
||||
|
||||
binding.detailSubhead.apply {
|
||||
text = context.getPlural(R.plurals.fmt_song_count, data.songs.size)
|
||||
|
|
|
@ -112,11 +112,11 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCal
|
|||
}
|
||||
DisplayMode.SHOW_ARTISTS -> {
|
||||
settingsManager.libArtistSort = sort
|
||||
mArtists.value = sort.sortModels(mArtists.value!!)
|
||||
mArtists.value = sort.sortParents(mArtists.value!!)
|
||||
}
|
||||
DisplayMode.SHOW_GENRES -> {
|
||||
settingsManager.libGenreSort = sort
|
||||
mGenres.value = sort.sortModels(mGenres.value!!)
|
||||
mGenres.value = sort.sortParents(mGenres.value!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -139,8 +139,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCal
|
|||
override fun onLoaded(musicStore: MusicStore) {
|
||||
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
|
||||
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
|
||||
mArtists.value = settingsManager.libArtistSort.sortModels(musicStore.artists)
|
||||
mGenres.value = settingsManager.libGenreSort.sortModels(musicStore.genres)
|
||||
mArtists.value = settingsManager.libArtistSort.sortParents(musicStore.artists)
|
||||
mGenres.value = settingsManager.libGenreSort.sortParents(musicStore.genres)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
|
|
@ -62,7 +62,7 @@ class AlbumListFragment : HomeListFragment() {
|
|||
SortMode.ASCENDING, SortMode.DESCENDING -> album.name.sliceArticle()
|
||||
.first().uppercase()
|
||||
|
||||
SortMode.ARTIST -> album.artist.name.sliceArticle()
|
||||
SortMode.ARTIST -> album.artist.resolvedName.sliceArticle()
|
||||
.first().uppercase()
|
||||
|
||||
SortMode.YEAR -> album.year.toString()
|
||||
|
|
|
@ -54,7 +54,8 @@ class ArtistListFragment : HomeListFragment() {
|
|||
|
||||
override val popupProvider: (Int) -> String
|
||||
get() = { idx ->
|
||||
homeModel.artists.value!![idx].name.sliceArticle().first().uppercase()
|
||||
homeModel.artists.value!![idx].resolvedName
|
||||
.sliceArticle().first().uppercase()
|
||||
}
|
||||
|
||||
class ArtistAdapter(
|
||||
|
|
|
@ -54,7 +54,8 @@ class GenreListFragment : HomeListFragment() {
|
|||
|
||||
override val popupProvider: (Int) -> String
|
||||
get() = { idx ->
|
||||
homeModel.genres.value!![idx].name.sliceArticle().first().uppercase()
|
||||
homeModel.genres.value!![idx].resolvedName
|
||||
.sliceArticle().first().uppercase()
|
||||
}
|
||||
|
||||
class GenreAdapter(
|
||||
|
|
|
@ -58,8 +58,9 @@ class SongListFragment : HomeListFragment() {
|
|||
SortMode.ASCENDING, SortMode.DESCENDING -> song.name.sliceArticle()
|
||||
.first().uppercase()
|
||||
|
||||
SortMode.ARTIST -> song.album.artist.name.sliceArticle()
|
||||
.first().uppercase()
|
||||
SortMode.ARTIST ->
|
||||
song.album.artist.resolvedName
|
||||
.sliceArticle().first().uppercase()
|
||||
|
||||
SortMode.ALBUM -> song.album.name.sliceArticle()
|
||||
.first().uppercase()
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
|
@ -27,46 +28,22 @@ import androidx.annotation.StringRes
|
|||
/**
|
||||
* The base data object for all music.
|
||||
* @property id The ID that is assigned to this object
|
||||
* @property name The name of this object (Such as a song title)
|
||||
*/
|
||||
sealed class BaseModel {
|
||||
abstract val id: Long
|
||||
abstract val name: String
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a versatile static hash for a music item that will not change when
|
||||
* MediaStore changes.
|
||||
*
|
||||
* The reason why this is used is down a couple of reasons:
|
||||
* - MediaStore will refresh the unique ID of a piece of media whenever the library
|
||||
* changes, which creates bad UX
|
||||
* - Using song names makes collisions too common to be reliable
|
||||
* - Hashing into an integer makes databases both smaller and more efficent
|
||||
*
|
||||
* This does lock me into a "Load everything at once, lol" architecture for Auxio, but I
|
||||
* think its worth it.
|
||||
*
|
||||
* @property hash A unique-ish hash for this media item
|
||||
*
|
||||
* TODO: Make this hash stronger
|
||||
*/
|
||||
sealed interface Hashable {
|
||||
val hash: Int
|
||||
sealed class Music : BaseModel() {
|
||||
abstract val name: String
|
||||
abstract val hash: Int
|
||||
}
|
||||
|
||||
/**
|
||||
* [BaseModel] variant that denotes that this object is a parent of other data objects, such
|
||||
* as an [Album] or [Artist]
|
||||
* @property displayName Name that handles the usage of [Genre.resolvedName]
|
||||
* and the normal [BaseModel.name]
|
||||
*/
|
||||
sealed class Parent : BaseModel(), Hashable {
|
||||
val displayName: String get() = if (this is Genre) {
|
||||
resolvedName
|
||||
} else {
|
||||
name
|
||||
}
|
||||
sealed class Parent : Music() {
|
||||
abstract val resolvedName: String
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -92,7 +69,7 @@ data class Song(
|
|||
val year: Int,
|
||||
val track: Int,
|
||||
val duration: Long
|
||||
) : BaseModel(), Hashable {
|
||||
) : Music() {
|
||||
private var mAlbum: Album? = null
|
||||
private var mGenre: Genre? = null
|
||||
|
||||
|
@ -145,6 +122,10 @@ data class Album(
|
|||
val totalDuration: String get() =
|
||||
songs.sumOf { it.seconds }.toDuration()
|
||||
|
||||
fun linkArtist(artist: Artist) {
|
||||
mArtist = artist
|
||||
}
|
||||
|
||||
override val hash: Int get() {
|
||||
var result = name.hashCode()
|
||||
result = 31 * result + artistName.hashCode()
|
||||
|
@ -152,9 +133,8 @@ data class Album(
|
|||
return result
|
||||
}
|
||||
|
||||
fun linkArtist(artist: Artist) {
|
||||
mArtist = artist
|
||||
}
|
||||
override val resolvedName: String
|
||||
get() = name
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -166,6 +146,7 @@ data class Album(
|
|||
data class Artist(
|
||||
override val id: Long,
|
||||
override val name: String,
|
||||
override val resolvedName: String,
|
||||
val albums: List<Album>
|
||||
) : Parent() {
|
||||
init {
|
||||
|
@ -190,44 +171,114 @@ data class Artist(
|
|||
/**
|
||||
* The data object for a genre. Inherits [Parent]
|
||||
* @property songs The list of all [Song]s in this genre.
|
||||
* @property resolvedName A name that has been resolved from its int-genre form to its named form.
|
||||
*/
|
||||
data class Genre(
|
||||
override val id: Long,
|
||||
override val name: String,
|
||||
override val resolvedName: String
|
||||
) : Parent() {
|
||||
private val mSongs = mutableListOf<Song>()
|
||||
val songs: List<Song> get() = mSongs
|
||||
|
||||
val resolvedName =
|
||||
name.getGenreNameCompat() ?: name
|
||||
|
||||
val totalDuration: String get() =
|
||||
songs.sumOf { it.seconds }.toDuration()
|
||||
|
||||
override val hash = name.hashCode()
|
||||
|
||||
fun linkSong(song: Song) {
|
||||
mSongs.add(song)
|
||||
song.linkGenre(this)
|
||||
}
|
||||
|
||||
override val hash = name.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* The string used for a header instance. This class is a bit complex, mostly because it revolves
|
||||
* around passing string resources that are then resolved by the view instead of passing a context
|
||||
* directly.
|
||||
*/
|
||||
sealed class HeaderString {
|
||||
/** A single string resource. */
|
||||
class Single(@StringRes val id: Int) : HeaderString()
|
||||
/** A string resource with an argument. */
|
||||
class WithArg(@StringRes val id: Int, val arg: Arg) : HeaderString()
|
||||
|
||||
/**
|
||||
* Resolve this instance into a string.
|
||||
*/
|
||||
fun resolve(context: Context): String {
|
||||
return when (this) {
|
||||
is Single -> context.getString(id)
|
||||
is WithArg -> context.getString(id, arg.resolve(context))
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return false
|
||||
|
||||
return when (this) {
|
||||
is Single -> other is Single && other.id == id
|
||||
is WithArg -> other is WithArg && other.id == id && other.arg == arg
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return when (this) {
|
||||
is Single -> id.hashCode()
|
||||
is WithArg -> 31 * id.hashCode() * arg.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An argument for the [WithArg] header string.
|
||||
*/
|
||||
sealed class Arg {
|
||||
/** A string resource to be used as the argument */
|
||||
class Resource(@StringRes val id: Int) : Arg()
|
||||
/** A string value to be used as the argument */
|
||||
class Value(val string: String) : Arg()
|
||||
|
||||
/** Resolve this argument instance into a string. */
|
||||
fun resolve(context: Context): String {
|
||||
return when (this) {
|
||||
is Resource -> context.getString(id)
|
||||
is Value -> string
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return false
|
||||
|
||||
return when (this) {
|
||||
is Resource -> other is Resource && other.id == id
|
||||
is Value -> other is Value && other.string == this.string
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return when (this) {
|
||||
is Resource -> id.hashCode()
|
||||
is Value -> 31 * string.hashCode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A data object used solely for the "Header" UI element.
|
||||
* @see HeaderString
|
||||
*/
|
||||
data class Header(
|
||||
override val id: Long,
|
||||
override val name: String,
|
||||
val string: HeaderString
|
||||
) : BaseModel()
|
||||
|
||||
/**
|
||||
* A data object used for an action header. Like [Header], but with a button.
|
||||
* Inherits [BaseModel].
|
||||
* @see HeaderString
|
||||
*/
|
||||
data class ActionHeader(
|
||||
override val id: Long,
|
||||
override val name: String,
|
||||
val string: HeaderString,
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val desc: Int,
|
||||
val onClick: (View) -> Unit,
|
||||
|
@ -239,7 +290,7 @@ data class ActionHeader(
|
|||
if (other !is ActionHeader) return false
|
||||
|
||||
if (id != other.id) return false
|
||||
if (name != other.name) return false
|
||||
if (string != other.string) return false
|
||||
if (icon != other.icon) return false
|
||||
if (desc != other.desc) return false
|
||||
|
||||
|
@ -248,7 +299,7 @@ data class ActionHeader(
|
|||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + string.hashCode()
|
||||
result = 31 * result + icon
|
||||
result = 31 * result + desc
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.music
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Audio.Genres
|
||||
import android.provider.MediaStore.Audio.Media
|
||||
import androidx.core.database.getStringOrNull
|
||||
|
@ -152,7 +153,7 @@ class MusicLoader(private val context: Context) {
|
|||
// No non-broken genre would be missing a name.
|
||||
val name = cursor.getStringOrNull(nameIndex) ?: continue
|
||||
|
||||
genres.add(Genre(id, name))
|
||||
genres.add(Genre(id, name, name.getGenreNameCompat() ?: name))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -261,18 +262,25 @@ class MusicLoader(private val context: Context) {
|
|||
val albumsByArtist = albums.groupBy { it.artistName }
|
||||
|
||||
albumsByArtist.forEach { entry ->
|
||||
val resolvedName = if (entry.key == MediaStore.UNKNOWN_STRING) {
|
||||
context.getString(R.string.def_artist)
|
||||
} else {
|
||||
entry.key
|
||||
}
|
||||
|
||||
// Because of our hacky album artist system, MediaStore artist IDs are unreliable.
|
||||
// Therefore we just use the hashCode of the artist name as our ID and move on.
|
||||
artists.add(
|
||||
Artist(
|
||||
id = entry.key.hashCode().toLong(),
|
||||
name = entry.key,
|
||||
resolvedName = resolvedName,
|
||||
albums = entry.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
artists = SortMode.ASCENDING.sortModels(artists).toMutableList()
|
||||
artists = SortMode.ASCENDING.sortParents(artists).toMutableList()
|
||||
|
||||
logD("Albums successfully linked into ${artists.size} artists")
|
||||
}
|
||||
|
@ -304,7 +312,8 @@ class MusicLoader(private val context: Context) {
|
|||
|
||||
val unknownGenre = Genre(
|
||||
id = Long.MIN_VALUE,
|
||||
name = context.getString(R.string.def_genre)
|
||||
name = MediaStore.UNKNOWN_STRING,
|
||||
resolvedName = context.getString(R.string.def_genre)
|
||||
)
|
||||
|
||||
songs.forEach { song ->
|
||||
|
|
|
@ -122,11 +122,7 @@ class PlaybackFragment : Fragment() {
|
|||
binding.playbackSeekBar.setProgress(pos)
|
||||
}
|
||||
|
||||
playbackModel.nextItemsInQueue.observe(viewLifecycleOwner) {
|
||||
updateQueueIcon(queueItem)
|
||||
}
|
||||
|
||||
playbackModel.userQueue.observe(viewLifecycleOwner) {
|
||||
playbackModel.displayQueue.observe(viewLifecycleOwner) {
|
||||
updateQueueIcon(queueItem)
|
||||
}
|
||||
|
||||
|
|
|
@ -21,14 +21,20 @@ package org.oxycblt.auxio.playback
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.ActionHeader
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Header
|
||||
import org.oxycblt.auxio.music.HeaderString
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Parent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
@ -89,16 +95,72 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
/** The current repeat mode, see [LoopMode] for more information */
|
||||
val loopMode: LiveData<LoopMode> get() = mLoopMode
|
||||
|
||||
/** The queue, without the previous items. */
|
||||
val nextItemsInQueue = Transformations.map(queue) { queue ->
|
||||
queue.slice((mIndex.value!! + 1) until queue.size)
|
||||
}
|
||||
|
||||
/** The combined queue data used for UIs, with header data included */
|
||||
val displayQueue = MediatorLiveData<List<BaseModel>>().apply {
|
||||
val combine: (userQueue: List<Song>, nextQueue: List<Song>) -> List<BaseModel> =
|
||||
{ userQueue, nextQueue ->
|
||||
val queue = mutableListOf<BaseModel>()
|
||||
|
||||
if (userQueue.isNotEmpty()) {
|
||||
queue += ActionHeader(
|
||||
id = -2,
|
||||
string = HeaderString.Single(R.string.lbl_next_user_queue),
|
||||
icon = R.drawable.ic_clear,
|
||||
desc = R.string.desc_clear_user_queue,
|
||||
onClick = { playbackManager.clearUserQueue() }
|
||||
)
|
||||
|
||||
queue += userQueue
|
||||
}
|
||||
|
||||
if (nextQueue.isNotEmpty()) {
|
||||
val parentName = parent.value?.name
|
||||
|
||||
queue += Header(
|
||||
id = -3,
|
||||
string = HeaderString.WithArg(
|
||||
R.string.fmt_next_from,
|
||||
if (parentName != null) {
|
||||
HeaderString.Arg.Value(parentName)
|
||||
} else {
|
||||
HeaderString.Arg.Resource(R.string.lbl_all_songs)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
queue += nextQueue
|
||||
}
|
||||
|
||||
queue
|
||||
}
|
||||
|
||||
// Do not move these around. The transformed value must be generated through this
|
||||
// observer call first before the userQueue source uses it assuming that it's not
|
||||
// null.
|
||||
addSource(nextItemsInQueue) { nextQueue ->
|
||||
value = combine(userQueue.value!!, nextQueue)
|
||||
}
|
||||
|
||||
addSource(userQueue) { userQueue ->
|
||||
value = combine(
|
||||
userQueue,
|
||||
requireNotNull(nextItemsInQueue.value) {
|
||||
"Transformed value was not generated yet."
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The position as SeekBar progress. */
|
||||
val positionAsProgress = Transformations.map(mPosition) {
|
||||
if (mSong.value != null) it.toInt() else 0
|
||||
}
|
||||
|
||||
/** The queue, without the previous items. */
|
||||
val nextItemsInQueue = Transformations.map(mQueue) {
|
||||
it.slice((mIndex.value!! + 1) until it.size)
|
||||
}
|
||||
|
||||
private val playbackManager = PlaybackStateManager.maybeGetInstance()
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
|
@ -316,13 +378,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
playbackManager.addToUserQueue(settingsManager.detailAlbumSort.sortAlbum(album))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the user queue entirely
|
||||
*/
|
||||
fun clearUserQueue() {
|
||||
playbackManager.clearUserQueue()
|
||||
}
|
||||
|
||||
// --- STATUS FUNCTIONS ---
|
||||
|
||||
/**
|
||||
|
|
|
@ -115,8 +115,6 @@ class QueueAdapter(
|
|||
fun removeItem(adapterIndex: Int) {
|
||||
data.removeAt(adapterIndex)
|
||||
|
||||
logD(data)
|
||||
|
||||
/*
|
||||
* If the data from the next queue is now entirely empty [Signified by a header at the
|
||||
* end, remove the next queue header as notify as such.
|
||||
|
|
|
@ -27,11 +27,7 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentQueueBinding
|
||||
import org.oxycblt.auxio.music.ActionHeader
|
||||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Header
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.applyEdge
|
||||
|
||||
|
@ -79,22 +75,13 @@ class QueueFragment : Fragment() {
|
|||
|
||||
// --- VIEWMODEL SETUP ----
|
||||
|
||||
playbackModel.userQueue.observe(viewLifecycleOwner) { userQueue ->
|
||||
if (userQueue.isEmpty() && playbackModel.nextItemsInQueue.value!!.isEmpty()) {
|
||||
playbackModel.displayQueue.observe(viewLifecycleOwner) { queue ->
|
||||
if (queue.isEmpty()) {
|
||||
findNavController().navigateUp()
|
||||
|
||||
return@observe
|
||||
}
|
||||
|
||||
queueAdapter.submitList(createQueueData())
|
||||
}
|
||||
|
||||
playbackModel.nextItemsInQueue.observe(viewLifecycleOwner) { nextQueue ->
|
||||
if (nextQueue.isEmpty() && playbackModel.userQueue.value!!.isEmpty()) {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
|
||||
queueAdapter.submitList(createQueueData())
|
||||
queueAdapter.submitList(queue.toMutableList())
|
||||
}
|
||||
|
||||
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
|
||||
|
@ -109,40 +96,4 @@ class QueueFragment : Fragment() {
|
|||
}
|
||||
|
||||
// --- QUEUE DATA ---
|
||||
|
||||
/**
|
||||
* Create the queue data that should be displayed
|
||||
* @return The list of headers/songs that should be displayed.
|
||||
*/
|
||||
private fun createQueueData(): MutableList<BaseModel> {
|
||||
val queue = mutableListOf<BaseModel>()
|
||||
val userQueue = playbackModel.userQueue.value!!
|
||||
val nextQueue = playbackModel.nextItemsInQueue.value!!
|
||||
|
||||
if (userQueue.isNotEmpty()) {
|
||||
queue += ActionHeader(
|
||||
id = -2,
|
||||
name = getString(R.string.lbl_next_user_queue),
|
||||
icon = R.drawable.ic_clear,
|
||||
desc = R.string.desc_clear_user_queue,
|
||||
onClick = { playbackModel.clearUserQueue() }
|
||||
)
|
||||
|
||||
queue += userQueue
|
||||
}
|
||||
|
||||
if (nextQueue.isNotEmpty()) {
|
||||
queue += Header(
|
||||
id = -3,
|
||||
name = getString(
|
||||
R.string.fmt_next_from,
|
||||
playbackModel.parent.value?.displayName ?: getString(R.string.lbl_all_songs)
|
||||
)
|
||||
)
|
||||
|
||||
queue += nextQueue
|
||||
}
|
||||
|
||||
return queue
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,7 +81,7 @@ class PlaybackNotification private constructor(
|
|||
*/
|
||||
fun setMetadata(song: Song, onDone: () -> Unit) {
|
||||
setContentTitle(song.name)
|
||||
setContentText(song.album.artist.name)
|
||||
setContentText(song.album.artist.resolvedName)
|
||||
|
||||
// On older versions of android [API <24], show the song's album on the subtext instead of
|
||||
// the current mode, as that makes more sense for the old style of media notifications.
|
||||
|
@ -125,7 +125,7 @@ class PlaybackNotification private constructor(
|
|||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
|
||||
// A blank parent always means that the mode is ALL_SONGS
|
||||
setSubText(parent?.displayName ?: context.getString(R.string.lbl_all_songs))
|
||||
setSubText(parent?.resolvedName ?: context.getString(R.string.lbl_all_songs))
|
||||
}
|
||||
|
||||
// --- NOTIFICATION ACTION BUILDERS ---
|
||||
|
|
|
@ -114,13 +114,15 @@ class PlaybackSessionConnector(
|
|||
return
|
||||
}
|
||||
|
||||
val artistName = song.album.artist.resolvedName
|
||||
|
||||
val builder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.album.artist.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, song.album.artist.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, song.album.artist.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.album.artist.name)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name)
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Header
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.AlbumViewHolder
|
||||
import org.oxycblt.auxio.ui.ArtistViewHolder
|
||||
|
@ -40,8 +41,8 @@ import org.oxycblt.auxio.ui.SongViewHolder
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
class SearchAdapter(
|
||||
private val doOnClick: (data: BaseModel) -> Unit,
|
||||
private val doOnLongClick: (view: View, data: BaseModel) -> Unit
|
||||
private val doOnClick: (data: Music) -> Unit,
|
||||
private val doOnLongClick: (view: View, data: Music) -> Unit
|
||||
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback<BaseModel>()) {
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
|
|
|
@ -35,9 +35,9 @@ import org.oxycblt.auxio.databinding.FragmentSearchBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Header
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
|
@ -54,10 +54,7 @@ import org.oxycblt.auxio.util.logD
|
|||
*/
|
||||
class SearchFragment : Fragment() {
|
||||
// SearchViewModel is only scoped to this Fragment
|
||||
private val searchModel: SearchViewModel by viewModels {
|
||||
SearchViewModel.Factory(requireContext())
|
||||
}
|
||||
|
||||
private val searchModel: SearchViewModel by viewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
|
@ -183,7 +180,7 @@ class SearchFragment : Fragment() {
|
|||
* Function that handles when an [item] is selected.
|
||||
* Handles all datatypes that are selectable.
|
||||
*/
|
||||
private fun onItemSelection(item: BaseModel, imm: InputMethodManager) {
|
||||
private fun onItemSelection(item: Music, imm: InputMethodManager) {
|
||||
if (item is Song) {
|
||||
playbackModel.playSong(item)
|
||||
|
||||
|
|
|
@ -18,17 +18,17 @@
|
|||
|
||||
package org.oxycblt.auxio.search
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Header
|
||||
import org.oxycblt.auxio.music.HeaderString
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
|
@ -38,7 +38,7 @@ import java.text.Normalizer
|
|||
* The [ViewModel] for the search functionality
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback {
|
||||
class SearchViewModel : ViewModel(), MusicStore.MusicCallback {
|
||||
private val mSearchResults = MutableLiveData(listOf<BaseModel>())
|
||||
private var mIsNavigating = false
|
||||
private var mFilterMode: DisplayMode? = null
|
||||
|
@ -51,11 +51,6 @@ class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback
|
|||
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
private val songHeader = Header(id = -1, context.getString(R.string.lbl_songs))
|
||||
private val albumHeader = Header(id = -1, context.getString(R.string.lbl_albums))
|
||||
private val artistHeader = Header(id = -1, context.getString(R.string.lbl_artists))
|
||||
private val genreHeader = Header(id = -1, context.getString(R.string.lbl_genres))
|
||||
|
||||
init {
|
||||
mFilterMode = settingsManager.searchFilterMode
|
||||
|
||||
|
@ -83,28 +78,28 @@ class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback
|
|||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
|
||||
musicStore.artists.filterByOrNull(query)?.let { artists ->
|
||||
results.add(artistHeader)
|
||||
results.add(Header(-1, HeaderString.Single(R.string.lbl_artists)))
|
||||
results.addAll(artists)
|
||||
}
|
||||
}
|
||||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
|
||||
musicStore.albums.filterByOrNull(query)?.let { albums ->
|
||||
results.add(albumHeader)
|
||||
results.add(Header(-1, HeaderString.Single(R.string.lbl_albums)))
|
||||
results.addAll(albums)
|
||||
}
|
||||
}
|
||||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
|
||||
musicStore.genres.filterByOrNull(query)?.let { genres ->
|
||||
results.add(genreHeader)
|
||||
results.add(Header(-1, HeaderString.Single(R.string.lbl_genres)))
|
||||
results.addAll(genres)
|
||||
}
|
||||
}
|
||||
|
||||
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
|
||||
musicStore.songs.filterByOrNull(query)?.let { songs ->
|
||||
results.add(songHeader)
|
||||
results.add(Header(-1, HeaderString.Single(R.string.lbl_songs)))
|
||||
results.addAll(songs)
|
||||
}
|
||||
}
|
||||
|
@ -136,7 +131,7 @@ class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback
|
|||
* Shortcut that will run a ignoreCase filter on a list and only return
|
||||
* a value if the resulting list is empty.
|
||||
*/
|
||||
private fun List<BaseModel>.filterByOrNull(value: String): List<BaseModel>? {
|
||||
private fun List<Music>.filterByOrNull(value: String): List<BaseModel>? {
|
||||
val filtered = filter {
|
||||
// First see if the normal item name will work. If that fails, try the "normalized"
|
||||
// [e.g all accented/unicode chars become latin chars] instead. Hopefully this
|
||||
|
@ -195,15 +190,4 @@ class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback
|
|||
super.onCleared()
|
||||
MusicStore.cancelAwaitInstance(this)
|
||||
}
|
||||
|
||||
class Factory(private val context: Context) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
check(modelClass.isAssignableFrom(SearchViewModel::class.java)) {
|
||||
"SearchViewModel.Factory does not support this class"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return SearchViewModel(context) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,8 +22,8 @@ import androidx.annotation.IdRes
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.BaseModel
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Parent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
|
@ -44,7 +44,8 @@ enum class SortMode(@IdRes val itemId: Int) {
|
|||
* Sort a list of songs.
|
||||
*
|
||||
* **Behavior:**
|
||||
* - [ASCENDING] & [DESCENDING]: See [sortModels]
|
||||
* - [ASCENDING]: By name after article, ascending
|
||||
* - [DESCENDING]: By name after article, descending
|
||||
* - [ARTIST]: Grouped by album and then sorted [ASCENDING] based off the artist name.
|
||||
* - [ALBUM]: Grouped by album and sorted [ASCENDING]
|
||||
* - [YEAR]: Grouped by album and sorted by year
|
||||
|
@ -54,7 +55,17 @@ enum class SortMode(@IdRes val itemId: Int) {
|
|||
*/
|
||||
fun sortSongs(songs: Collection<Song>): List<Song> {
|
||||
return when (this) {
|
||||
ASCENDING, DESCENDING -> sortModels(songs)
|
||||
ASCENDING -> songs.sortedWith(
|
||||
compareBy(String.CASE_INSENSITIVE_ORDER) { song ->
|
||||
song.name.sliceArticle()
|
||||
}
|
||||
)
|
||||
|
||||
DESCENDING -> songs.sortedWith(
|
||||
compareByDescending(String.CASE_INSENSITIVE_ORDER) { song ->
|
||||
song.name.sliceArticle()
|
||||
}
|
||||
)
|
||||
|
||||
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
|
||||
ASCENDING.sortAlbum(album)
|
||||
|
@ -66,7 +77,8 @@ enum class SortMode(@IdRes val itemId: Int) {
|
|||
* Sort a list of albums.
|
||||
*
|
||||
* **Behavior:**
|
||||
* - [ASCENDING] & [DESCENDING]: See [sortModels]
|
||||
* - [ASCENDING]: By name after article, ascending
|
||||
* - [DESCENDING]: By name after article, descending
|
||||
* - [ARTIST]: Grouped by artist and sorted [ASCENDING]
|
||||
* - [ALBUM]: [ASCENDING]
|
||||
* - [YEAR]: Sorted by year
|
||||
|
@ -75,43 +87,40 @@ enum class SortMode(@IdRes val itemId: Int) {
|
|||
*/
|
||||
fun sortAlbums(albums: Collection<Album>): List<Album> {
|
||||
return when (this) {
|
||||
ASCENDING, DESCENDING -> sortModels(albums)
|
||||
ASCENDING, DESCENDING -> sortParents(albums)
|
||||
|
||||
ARTIST -> ASCENDING.sortModels(albums.groupBy { it.artist }.keys)
|
||||
ARTIST -> ASCENDING.sortParents(albums.groupBy { it.artist }.keys)
|
||||
.flatMap { YEAR.sortAlbums(it.albums) }
|
||||
|
||||
ALBUM -> ASCENDING.sortModels(albums)
|
||||
ALBUM -> ASCENDING.sortParents(albums)
|
||||
|
||||
YEAR -> albums.sortedByDescending { it.year }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list of generic [BaseModel] instances.
|
||||
* Sort a generic list of [Parent] instances.
|
||||
*
|
||||
* **Behavior:**
|
||||
* - [ASCENDING]: Sorted by name, ascending
|
||||
* - [DESCENDING]: Sorted by name, descending
|
||||
* - Same list is returned otherwise.
|
||||
*
|
||||
* Names will be treated as case-insensitive. Articles like "the" and "a" will be skipped
|
||||
* to line up with MediaStore behavior.
|
||||
* - [ASCENDING]: By name after article, ascending
|
||||
* - [DESCENDING]: By name after article, descending
|
||||
* - Same parent list is returned otherwise.
|
||||
*/
|
||||
fun <T : BaseModel> sortModels(models: Collection<T>): List<T> {
|
||||
fun <T : Parent> sortParents(parents: Collection<T>): List<T> {
|
||||
return when (this) {
|
||||
ASCENDING -> models.sortedWith(
|
||||
ASCENDING -> parents.sortedWith(
|
||||
compareBy(String.CASE_INSENSITIVE_ORDER) { model ->
|
||||
model.name.sliceArticle()
|
||||
model.resolvedName.sliceArticle()
|
||||
}
|
||||
)
|
||||
|
||||
DESCENDING -> models.sortedWith(
|
||||
DESCENDING -> parents.sortedWith(
|
||||
compareByDescending(String.CASE_INSENSITIVE_ORDER) { model ->
|
||||
model.name.sliceArticle()
|
||||
model.resolvedName.sliceArticle()
|
||||
}
|
||||
)
|
||||
|
||||
else -> models.toList()
|
||||
else -> parents.toList()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -80,7 +80,7 @@
|
|||
android:layout_marginStart="@dimen/spacing_mid_large"
|
||||
android:layout_marginEnd="@dimen/spacing_mid_large"
|
||||
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}"
|
||||
android:text="@{song.album.artist.name}"
|
||||
android:text="@{song.album.artist.resolvedName}"
|
||||
app:layout_constraintBottom_toTopOf="@+id/playback_album"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
|
|
|
@ -82,7 +82,7 @@
|
|||
android:layout_marginStart="@dimen/spacing_mid_large"
|
||||
android:layout_marginEnd="@dimen/spacing_mid_large"
|
||||
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}"
|
||||
android:text="@{song.album.artist.name}"
|
||||
android:text="@{song.album.artist.resolvedName}"
|
||||
app:layout_constraintBottom_toTopOf="@+id/playback_album"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
|
|
|
@ -70,7 +70,7 @@
|
|||
android:layout_marginStart="@dimen/spacing_mid_huge"
|
||||
android:layout_marginEnd="@dimen/spacing_mid_huge"
|
||||
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}"
|
||||
android:text="@{song.album.artist.name}"
|
||||
android:text="@{song.album.artist.resolvedName}"
|
||||
app:layout_constraintBottom_toTopOf="@+id/playback_album"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
android:layout_marginStart="@dimen/spacing_small"
|
||||
android:layout_marginEnd="@dimen/spacing_small"
|
||||
android:ellipsize="end"
|
||||
android:text="@{@string/fmt_two(song.album.artist.name, song.album.name)}"
|
||||
android:text="@{@string/fmt_two(song.album.artist.resolvedName, song.album.name)}"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/playback_cover"
|
||||
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
|
||||
app:layout_constraintStart_toEndOf="@+id/playback_cover"
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
android:layout_marginStart="@dimen/spacing_mid_large"
|
||||
android:layout_marginEnd="@dimen/spacing_mid_large"
|
||||
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}"
|
||||
android:text="@{song.album.artist.name}"
|
||||
android:text="@{song.album.artist.resolvedName}"
|
||||
app:layout_constraintBottom_toTopOf="@+id/playback_album"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
style="@style/Widget.Auxio.TextView.Header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{header.name}"
|
||||
android:text="@{header.string.resolve(context)}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
style="@style/Widget.Auxio.TextView.Item.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{@string/fmt_two(album.artist.name, @plurals/fmt_song_count(album.songs.size, album.songs.size))}"
|
||||
android:text="@{@string/fmt_two(album.artist.resolvedName, @plurals/fmt_song_count(album.songs.size, album.songs.size))}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/album_cover"
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<ImageView
|
||||
android:id="@+id/artist_image"
|
||||
style="@style/Widget.Auxio.Image.Normal"
|
||||
android:contentDescription="@{@string/desc_artist_image(artist.name)}"
|
||||
android:contentDescription="@{@string/desc_artist_image(artist.resolvedName)}"
|
||||
app:artistImage="@{artist}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
@ -28,7 +28,7 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Widget.Auxio.TextView.Item.Primary"
|
||||
android:text="@{artist.name}"
|
||||
android:text="@{artist.resolvedName}"
|
||||
app:layout_constraintBottom_toTopOf="@+id/artist_details"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/artist_image"
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
<TextView
|
||||
android:id="@+id/album_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Widget.Auxio.TextView.Item.Primary"
|
||||
android:textColor="@color/sel_accented_primary"
|
||||
|
@ -39,7 +39,7 @@
|
|||
|
||||
<TextView
|
||||
android:id="@+id/album_year"
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Widget.Auxio.TextView.Item.Secondary"
|
||||
android:text="@{album.year != 0 ? String.valueOf(album.year) : @string/def_date}"
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<ImageView
|
||||
android:id="@+id/genre_image"
|
||||
style="@style/Widget.Auxio.Image.Normal"
|
||||
android:contentDescription="@{@string/desc_genre_image(genre.name)}"
|
||||
android:contentDescription="@{@string/desc_genre_image(genre.resolvedName)}"
|
||||
app:genreImage="@{genre}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/spacing_medium"
|
||||
android:text="@{@string/fmt_two(song.album.artist.name, song.album.name)}"
|
||||
android:text="@{@string/fmt_two(song.album.artist.resolvedName, song.album.name)}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/song_duration"
|
||||
app:layout_constraintStart_toEndOf="@+id/album_cover"
|
||||
|
@ -53,7 +53,7 @@
|
|||
|
||||
<TextView
|
||||
android:id="@+id/song_duration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Widget.Auxio.TextView.Item.Secondary"
|
||||
android:ellipsize="none"
|
||||
|
|
|
@ -15,6 +15,6 @@
|
|||
style="@style/Widget.Auxio.TextView.Header"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:text="@{header.name}"
|
||||
android:text="@{header.string.resolve(context)}"
|
||||
tools:text="Songs" />
|
||||
</layout>
|
|
@ -69,7 +69,7 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/spacing_medium"
|
||||
android:text="@{@string/fmt_two(song.album.artist.name, song.album.name)}"
|
||||
android:text="@{@string/fmt_two(song.album.artist.resolvedName, song.album.name)}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
|
||||
app:layout_constraintStart_toEndOf="@+id/album_cover"
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
style="@style/Widget.Auxio.TextView.Item.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{@string/fmt_two(song.album.artist.name, song.album.name)}"
|
||||
android:text="@{@string/fmt_two(song.album.artist.resolvedName, song.album.name)}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/album_cover"
|
||||
|
|
|
@ -41,4 +41,9 @@
|
|||
|
||||
<dimen name="popup_min_width">78dp</dimen>
|
||||
<dimen name="popup_padding_end">28dp</dimen>
|
||||
|
||||
<dimen name="widget_width_min">176dp</dimen>
|
||||
<dimen name="widget_height_min">180dp</dimen>
|
||||
<dimen name="widget_width_def">@dimen/widget_width_min</dimen>
|
||||
<dimen name="widget_height_def">180dp</dimen>
|
||||
</resources>
|
|
@ -134,8 +134,8 @@
|
|||
<string name="desc_artist_image">Artist Image for %s</string>
|
||||
<string name="desc_genre_image">Genre Image for %s</string>
|
||||
|
||||
|
||||
<!-- Default Namespace | Placeholder values -->
|
||||
<string name="def_artist">Unknown Artist</string>
|
||||
<string name="def_genre">Unknown Genre</string>
|
||||
<string name="def_date">No Date</string>
|
||||
<string name="def_playback">No music playing</string>
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/info_widget_desc"
|
||||
android:initialLayout="@layout/widget_small"
|
||||
android:minResizeWidth="176dp"
|
||||
android:minResizeHeight="152dp"
|
||||
android:previewLayout="@layout/widget_small"
|
||||
android:initialLayout="@layout/widget_minimal"
|
||||
android:minResizeWidth="@dimen/widget_width_min"
|
||||
android:minResizeHeight="@dimen/widget_height_min"
|
||||
android:previewLayout="@layout/widget_minimal"
|
||||
android:previewImage="@drawable/ui_widget_preview"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:minWidth="176dp"
|
||||
android:minHeight="180dp"
|
||||
android:minWidth="@dimen/widget_width_def"
|
||||
android:minHeight="@dimen/widget_height_def"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="3"
|
||||
android:updatePeriodMillis="0"
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_small"
|
||||
android:minWidth="176dp"
|
||||
android:minHeight="180dp"
|
||||
android:minResizeWidth="176dp"
|
||||
android:minResizeHeight="152dp"
|
||||
android:initialLayout="@layout/widget_minimal"
|
||||
android:minWidth="@dimen/widget_width_min"
|
||||
android:minHeight="@dimen/widget_height_min"
|
||||
android:minResizeWidth="@dimen/widget_width_def"
|
||||
android:minResizeHeight="@dimen/widget_height_def"
|
||||
android:previewImage="@drawable/ui_widget_preview"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="0"
|
||||
|
|
Loading…
Reference in a new issue