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:
OxygenCobalt 2021-10-28 19:09:54 -06:00
parent 51ba72d861
commit df49e2765f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
45 changed files with 315 additions and 245 deletions

View file

@ -71,7 +71,10 @@ fun ImageView.bindGenreImage(genre: Genre?) {
load(genre, R.drawable.ic_genre, MosaicFetcher(context)) load(genre, R.drawable.ic_genre, MosaicFetcher(context))
if (genre != null) { if (genre != null) {
contentDescription = context.getString(R.string.desc_genre_image, genre.name) contentDescription = context.getString(
R.string.desc_genre_image,
genre.resolvedName
)
} }
} }

View file

@ -52,7 +52,7 @@ class AlbumDetailFragment : DetailFragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
detailModel.setAlbum(args.albumId, requireContext()) detailModel.setAlbum(args.albumId)
val detailAdapter = AlbumDetailAdapter( val detailAdapter = AlbumDetailAdapter(
playbackModel, detailModel, playbackModel, detailModel,

View file

@ -48,7 +48,7 @@ class ArtistDetailFragment : DetailFragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
detailModel.setArtist(args.artistId, requireContext()) detailModel.setArtist(args.artistId)
val detailAdapter = ArtistDetailAdapter( val detailAdapter = ArtistDetailAdapter(
playbackModel, playbackModel,

View file

@ -74,7 +74,7 @@ abstract class DetailFragment : Fragment() {
super.onStop() super.onStop()
// Cancel all pending menus when this fragment stops to prevent bugs/crashes // 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 -> setOnMenuItemClickListener { item ->
item.isChecked = true item.isChecked = true
detailModel.finishShowMenu(SortMode.fromId(item.itemId)!!, config.anchor.context) detailModel.finishShowMenu(SortMode.fromId(item.itemId)!!)
true true
} }
setOnDismissListener { setOnDismissListener {
detailModel.finishShowMenu(null, config.anchor.context) detailModel.finishShowMenu(null)
} }
if (showItem != null) { if (showItem != null) {

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.content.Context
import android.view.View import android.view.View
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData 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.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.HeaderString
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
@ -80,50 +80,50 @@ class DetailViewModel : ViewModel() {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
fun setGenre(id: Long, context: Context) { fun setGenre(id: Long) {
if (mCurGenre.value?.id == id) return if (mCurGenre.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurGenre.value = musicStore.genres.find { it.id == id } 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 if (mCurArtist.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurArtist.value = musicStore.artists.find { it.id == id } 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 if (mCurAlbum.value?.id == id) return
val musicStore = MusicStore.requireInstance() val musicStore = MusicStore.requireInstance()
mCurAlbum.value = musicStore.albums.find { it.id == id } mCurAlbum.value = musicStore.albums.find { it.id == id }
refreshAlbumData(context) refreshAlbumData()
} }
/** /**
* Mark that the menu process is done with the new [SortMode]. * Mark that the menu process is done with the new [SortMode].
* Pass null if there was no change. * Pass null if there was no change.
*/ */
fun finishShowMenu(newMode: SortMode?, context: Context) { fun finishShowMenu(newMode: SortMode?) {
mShowMenu.value = null mShowMenu.value = null
if (newMode != null) { if (newMode != null) {
when (currentMenuContext) { when (currentMenuContext) {
DisplayMode.SHOW_ALBUMS -> { DisplayMode.SHOW_ALBUMS -> {
settingsManager.detailAlbumSort = newMode settingsManager.detailAlbumSort = newMode
refreshAlbumData(context) refreshAlbumData()
} }
DisplayMode.SHOW_ARTISTS -> { DisplayMode.SHOW_ARTISTS -> {
settingsManager.detailArtistSort = newMode settingsManager.detailArtistSort = newMode
refreshArtistData(context) refreshArtistData()
} }
DisplayMode.SHOW_GENRES -> { DisplayMode.SHOW_GENRES -> {
settingsManager.detailGenreSort = newMode settingsManager.detailGenreSort = newMode
refreshGenreData(context) refreshGenreData()
} }
else -> {} else -> {}
} }
@ -153,13 +153,13 @@ class DetailViewModel : ViewModel() {
isNavigating = navigating isNavigating = navigating
} }
private fun refreshGenreData(context: Context) { private fun refreshGenreData() {
val data = mutableListOf<BaseModel>(curGenre.value!!) val data = mutableListOf<BaseModel>(curGenre.value!!)
data.add( data.add(
ActionHeader( ActionHeader(
id = -2, id = -2,
name = context.getString(R.string.lbl_songs), string = HeaderString.Single(R.string.lbl_songs),
icon = R.drawable.ic_sort, icon = R.drawable.ic_sort,
desc = R.string.lbl_sort, desc = R.string.lbl_sort,
onClick = { view -> onClick = { view ->
@ -174,14 +174,14 @@ class DetailViewModel : ViewModel() {
mGenreData.value = data mGenreData.value = data
} }
private fun refreshArtistData(context: Context) { private fun refreshArtistData() {
val artist = curArtist.value!! val artist = curArtist.value!!
val data = mutableListOf<BaseModel>(artist) val data = mutableListOf<BaseModel>(artist)
data.add( data.add(
Header( Header(
id = -2, 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( data.add(
ActionHeader( ActionHeader(
id = -3, id = -3,
name = context.getString(R.string.lbl_songs), string = HeaderString.Single(R.string.lbl_songs),
icon = R.drawable.ic_sort, icon = R.drawable.ic_sort,
desc = R.string.lbl_sort, desc = R.string.lbl_sort,
onClick = { view -> onClick = { view ->
@ -205,13 +205,13 @@ class DetailViewModel : ViewModel() {
mArtistData.value = data.toList() mArtistData.value = data.toList()
} }
private fun refreshAlbumData(context: Context) { private fun refreshAlbumData() {
val data = mutableListOf<BaseModel>(curAlbum.value!!) val data = mutableListOf<BaseModel>(curAlbum.value!!)
data.add( data.add(
ActionHeader( ActionHeader(
id = -2, id = -2,
name = context.getString(R.string.lbl_songs), string = HeaderString.Single(R.string.lbl_songs),
icon = R.drawable.ic_sort, icon = R.drawable.ic_sort,
desc = R.string.lbl_sort, desc = R.string.lbl_sort,
onClick = { view -> onClick = { view ->

View file

@ -48,7 +48,7 @@ class GenreDetailFragment : DetailFragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
detailModel.setGenre(args.genreId, requireContext()) detailModel.setGenre(args.genreId)
val detailAdapter = GenreDetailAdapter( val detailAdapter = GenreDetailAdapter(
playbackModel, playbackModel,

View file

@ -145,7 +145,7 @@ class AlbumDetailAdapter(
binding.detailName.text = data.name binding.detailName.text = data.name
binding.detailSubhead.apply { binding.detailSubhead.apply {
text = data.artist.name text = data.artist.resolvedName
setOnClickListener { setOnClickListener {
detailModel.navToItem(data.artist) detailModel.navToItem(data.artist)

View file

@ -190,10 +190,13 @@ class ArtistDetailAdapter(
binding.detailCover.apply { binding.detailCover.apply {
bindArtistImage(data) 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 binding.detailSubhead.text = data.genre?.resolvedName
?: context.getString(R.string.def_genre) ?: context.getString(R.string.def_genre)

View file

@ -136,10 +136,13 @@ class GenreDetailAdapter(
binding.detailCover.apply { binding.detailCover.apply {
bindGenreImage(data) 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 { binding.detailSubhead.apply {
text = context.getPlural(R.plurals.fmt_song_count, data.songs.size) text = context.getPlural(R.plurals.fmt_song_count, data.songs.size)

View file

@ -112,11 +112,11 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCal
} }
DisplayMode.SHOW_ARTISTS -> { DisplayMode.SHOW_ARTISTS -> {
settingsManager.libArtistSort = sort settingsManager.libArtistSort = sort
mArtists.value = sort.sortModels(mArtists.value!!) mArtists.value = sort.sortParents(mArtists.value!!)
} }
DisplayMode.SHOW_GENRES -> { DisplayMode.SHOW_GENRES -> {
settingsManager.libGenreSort = sort 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) { override fun onLoaded(musicStore: MusicStore) {
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs) mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums) mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.sortModels(musicStore.artists) mArtists.value = settingsManager.libArtistSort.sortParents(musicStore.artists)
mGenres.value = settingsManager.libGenreSort.sortModels(musicStore.genres) mGenres.value = settingsManager.libGenreSort.sortParents(musicStore.genres)
} }
override fun onCleared() { override fun onCleared() {

View file

@ -62,7 +62,7 @@ class AlbumListFragment : HomeListFragment() {
SortMode.ASCENDING, SortMode.DESCENDING -> album.name.sliceArticle() SortMode.ASCENDING, SortMode.DESCENDING -> album.name.sliceArticle()
.first().uppercase() .first().uppercase()
SortMode.ARTIST -> album.artist.name.sliceArticle() SortMode.ARTIST -> album.artist.resolvedName.sliceArticle()
.first().uppercase() .first().uppercase()
SortMode.YEAR -> album.year.toString() SortMode.YEAR -> album.year.toString()

View file

@ -54,7 +54,8 @@ class ArtistListFragment : HomeListFragment() {
override val popupProvider: (Int) -> String override val popupProvider: (Int) -> String
get() = { idx -> get() = { idx ->
homeModel.artists.value!![idx].name.sliceArticle().first().uppercase() homeModel.artists.value!![idx].resolvedName
.sliceArticle().first().uppercase()
} }
class ArtistAdapter( class ArtistAdapter(

View file

@ -54,7 +54,8 @@ class GenreListFragment : HomeListFragment() {
override val popupProvider: (Int) -> String override val popupProvider: (Int) -> String
get() = { idx -> get() = { idx ->
homeModel.genres.value!![idx].name.sliceArticle().first().uppercase() homeModel.genres.value!![idx].resolvedName
.sliceArticle().first().uppercase()
} }
class GenreAdapter( class GenreAdapter(

View file

@ -58,8 +58,9 @@ class SongListFragment : HomeListFragment() {
SortMode.ASCENDING, SortMode.DESCENDING -> song.name.sliceArticle() SortMode.ASCENDING, SortMode.DESCENDING -> song.name.sliceArticle()
.first().uppercase() .first().uppercase()
SortMode.ARTIST -> song.album.artist.name.sliceArticle() SortMode.ARTIST ->
.first().uppercase() song.album.artist.resolvedName
.sliceArticle().first().uppercase()
SortMode.ALBUM -> song.album.name.sliceArticle() SortMode.ALBUM -> song.album.name.sliceArticle()
.first().uppercase() .first().uppercase()

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context
import android.view.View import android.view.View
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
@ -27,46 +28,22 @@ import androidx.annotation.StringRes
/** /**
* The base data object for all music. * The base data object for all music.
* @property id The ID that is assigned to this object * @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 { sealed class BaseModel {
abstract val id: Long abstract val id: Long
abstract val name: String
} }
/** sealed class Music : BaseModel() {
* Provides a versatile static hash for a music item that will not change when abstract val name: String
* MediaStore changes. abstract val hash: Int
*
* 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
} }
/** /**
* [BaseModel] variant that denotes that this object is a parent of other data objects, such * [BaseModel] variant that denotes that this object is a parent of other data objects, such
* as an [Album] or [Artist] * 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 { sealed class Parent : Music() {
val displayName: String get() = if (this is Genre) { abstract val resolvedName: String
resolvedName
} else {
name
}
} }
/** /**
@ -92,7 +69,7 @@ data class Song(
val year: Int, val year: Int,
val track: Int, val track: Int,
val duration: Long val duration: Long
) : BaseModel(), Hashable { ) : Music() {
private var mAlbum: Album? = null private var mAlbum: Album? = null
private var mGenre: Genre? = null private var mGenre: Genre? = null
@ -145,6 +122,10 @@ data class Album(
val totalDuration: String get() = val totalDuration: String get() =
songs.sumOf { it.seconds }.toDuration() songs.sumOf { it.seconds }.toDuration()
fun linkArtist(artist: Artist) {
mArtist = artist
}
override val hash: Int get() { override val hash: Int get() {
var result = name.hashCode() var result = name.hashCode()
result = 31 * result + artistName.hashCode() result = 31 * result + artistName.hashCode()
@ -152,9 +133,8 @@ data class Album(
return result return result
} }
fun linkArtist(artist: Artist) { override val resolvedName: String
mArtist = artist get() = name
}
} }
/** /**
@ -166,6 +146,7 @@ data class Album(
data class Artist( data class Artist(
override val id: Long, override val id: Long,
override val name: String, override val name: String,
override val resolvedName: String,
val albums: List<Album> val albums: List<Album>
) : Parent() { ) : Parent() {
init { init {
@ -190,44 +171,114 @@ data class Artist(
/** /**
* The data object for a genre. Inherits [Parent] * The data object for a genre. Inherits [Parent]
* @property songs The list of all [Song]s in this genre. * @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( data class Genre(
override val id: Long, override val id: Long,
override val name: String, override val name: String,
override val resolvedName: String
) : Parent() { ) : Parent() {
private val mSongs = mutableListOf<Song>() private val mSongs = mutableListOf<Song>()
val songs: List<Song> get() = mSongs val songs: List<Song> get() = mSongs
val resolvedName =
name.getGenreNameCompat() ?: name
val totalDuration: String get() = val totalDuration: String get() =
songs.sumOf { it.seconds }.toDuration() songs.sumOf { it.seconds }.toDuration()
override val hash = name.hashCode()
fun linkSong(song: Song) { fun linkSong(song: Song) {
mSongs.add(song) mSongs.add(song)
song.linkGenre(this) 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. * A data object used solely for the "Header" UI element.
* @see HeaderString
*/ */
data class Header( data class Header(
override val id: Long, override val id: Long,
override val name: String, val string: HeaderString
) : BaseModel() ) : BaseModel()
/** /**
* A data object used for an action header. Like [Header], but with a button. * A data object used for an action header. Like [Header], but with a button.
* Inherits [BaseModel]. * @see HeaderString
*/ */
data class ActionHeader( data class ActionHeader(
override val id: Long, override val id: Long,
override val name: String, val string: HeaderString,
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
@StringRes val desc: Int, @StringRes val desc: Int,
val onClick: (View) -> Unit, val onClick: (View) -> Unit,
@ -239,7 +290,7 @@ data class ActionHeader(
if (other !is ActionHeader) return false if (other !is ActionHeader) return false
if (id != other.id) 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 (icon != other.icon) return false
if (desc != other.desc) return false if (desc != other.desc) return false
@ -248,7 +299,7 @@ data class ActionHeader(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = id.hashCode() var result = id.hashCode()
result = 31 * result + name.hashCode() result = 31 * result + string.hashCode()
result = 31 * result + icon result = 31 * result + icon
result = 31 * result + desc result = 31 * result + desc

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.music
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.provider.MediaStore
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 androidx.core.database.getStringOrNull
@ -152,7 +153,7 @@ class MusicLoader(private val context: Context) {
// No non-broken genre would be missing a name. // No non-broken genre would be missing a name.
val name = cursor.getStringOrNull(nameIndex) ?: continue 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 } val albumsByArtist = albums.groupBy { it.artistName }
albumsByArtist.forEach { entry -> 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. // 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. // Therefore we just use the hashCode of the artist name as our ID and move on.
artists.add( artists.add(
Artist( Artist(
id = entry.key.hashCode().toLong(), id = entry.key.hashCode().toLong(),
name = entry.key, name = entry.key,
resolvedName = resolvedName,
albums = entry.value albums = entry.value
) )
) )
} }
artists = SortMode.ASCENDING.sortModels(artists).toMutableList() artists = SortMode.ASCENDING.sortParents(artists).toMutableList()
logD("Albums successfully linked into ${artists.size} artists") logD("Albums successfully linked into ${artists.size} artists")
} }
@ -304,7 +312,8 @@ class MusicLoader(private val context: Context) {
val unknownGenre = Genre( val unknownGenre = Genre(
id = Long.MIN_VALUE, 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 -> songs.forEach { song ->

View file

@ -122,11 +122,7 @@ class PlaybackFragment : Fragment() {
binding.playbackSeekBar.setProgress(pos) binding.playbackSeekBar.setProgress(pos)
} }
playbackModel.nextItemsInQueue.observe(viewLifecycleOwner) { playbackModel.displayQueue.observe(viewLifecycleOwner) {
updateQueueIcon(queueItem)
}
playbackModel.userQueue.observe(viewLifecycleOwner) {
updateQueueIcon(queueItem) updateQueueIcon(queueItem)
} }

View file

@ -21,14 +21,20 @@ package org.oxycblt.auxio.playback
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre 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.MusicStore
import org.oxycblt.auxio.music.Parent import org.oxycblt.auxio.music.Parent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
@ -89,16 +95,72 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
/** The current repeat mode, see [LoopMode] for more information */ /** The current repeat mode, see [LoopMode] for more information */
val loopMode: LiveData<LoopMode> get() = mLoopMode 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. */ /** The position as SeekBar progress. */
val positionAsProgress = Transformations.map(mPosition) { val positionAsProgress = Transformations.map(mPosition) {
if (mSong.value != null) it.toInt() else 0 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 playbackManager = PlaybackStateManager.maybeGetInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
@ -316,13 +378,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.addToUserQueue(settingsManager.detailAlbumSort.sortAlbum(album)) playbackManager.addToUserQueue(settingsManager.detailAlbumSort.sortAlbum(album))
} }
/**
* Clear the user queue entirely
*/
fun clearUserQueue() {
playbackManager.clearUserQueue()
}
// --- STATUS FUNCTIONS --- // --- STATUS FUNCTIONS ---
/** /**

View file

@ -115,8 +115,6 @@ class QueueAdapter(
fun removeItem(adapterIndex: Int) { fun removeItem(adapterIndex: Int) {
data.removeAt(adapterIndex) data.removeAt(adapterIndex)
logD(data)
/* /*
* If the data from the next queue is now entirely empty [Signified by a header at the * 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. * end, remove the next queue header as notify as such.

View file

@ -27,11 +27,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentQueueBinding 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.playback.PlaybackViewModel
import org.oxycblt.auxio.util.applyEdge import org.oxycblt.auxio.util.applyEdge
@ -79,22 +75,13 @@ class QueueFragment : Fragment() {
// --- VIEWMODEL SETUP ---- // --- VIEWMODEL SETUP ----
playbackModel.userQueue.observe(viewLifecycleOwner) { userQueue -> playbackModel.displayQueue.observe(viewLifecycleOwner) { queue ->
if (userQueue.isEmpty() && playbackModel.nextItemsInQueue.value!!.isEmpty()) { if (queue.isEmpty()) {
findNavController().navigateUp() findNavController().navigateUp()
return@observe return@observe
} }
queueAdapter.submitList(createQueueData()) queueAdapter.submitList(queue.toMutableList())
}
playbackModel.nextItemsInQueue.observe(viewLifecycleOwner) { nextQueue ->
if (nextQueue.isEmpty() && playbackModel.userQueue.value!!.isEmpty()) {
findNavController().navigateUp()
}
queueAdapter.submitList(createQueueData())
} }
playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling -> playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling ->
@ -109,40 +96,4 @@ class QueueFragment : Fragment() {
} }
// --- QUEUE DATA --- // --- 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
}
} }

View file

@ -81,7 +81,7 @@ class PlaybackNotification private constructor(
*/ */
fun setMetadata(song: Song, onDone: () -> Unit) { fun setMetadata(song: Song, onDone: () -> Unit) {
setContentTitle(song.name) 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 // 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. // 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 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
// A blank parent always means that the mode is ALL_SONGS // 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 --- // --- NOTIFICATION ACTION BUILDERS ---

View file

@ -114,13 +114,15 @@ class PlaybackSessionConnector(
return return
} }
val artistName = song.album.artist.resolvedName
val builder = MediaMetadataCompat.Builder() val builder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.name) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.name)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.album.artist.name) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, song.album.artist.name) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, song.album.artist.name) .putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.album.artist.name) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name)
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)

View file

@ -27,6 +27,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.AlbumViewHolder import org.oxycblt.auxio.ui.AlbumViewHolder
import org.oxycblt.auxio.ui.ArtistViewHolder import org.oxycblt.auxio.ui.ArtistViewHolder
@ -40,8 +41,8 @@ import org.oxycblt.auxio.ui.SongViewHolder
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SearchAdapter( class SearchAdapter(
private val doOnClick: (data: BaseModel) -> Unit, private val doOnClick: (data: Music) -> Unit,
private val doOnLongClick: (view: View, data: BaseModel) -> Unit private val doOnLongClick: (view: View, data: Music) -> Unit
) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback<BaseModel>()) { ) : ListAdapter<BaseModel, RecyclerView.ViewHolder>(DiffCallback<BaseModel>()) {
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {

View file

@ -35,9 +35,9 @@ import org.oxycblt.auxio.databinding.FragmentSearchBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
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.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
@ -54,10 +54,7 @@ import org.oxycblt.auxio.util.logD
*/ */
class SearchFragment : Fragment() { class SearchFragment : Fragment() {
// SearchViewModel is only scoped to this Fragment // SearchViewModel is only scoped to this Fragment
private val searchModel: SearchViewModel by viewModels { private val searchModel: SearchViewModel by viewModels()
SearchViewModel.Factory(requireContext())
}
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
@ -183,7 +180,7 @@ class SearchFragment : Fragment() {
* Function that handles when an [item] is selected. * Function that handles when an [item] is selected.
* Handles all datatypes that are selectable. * Handles all datatypes that are selectable.
*/ */
private fun onItemSelection(item: BaseModel, imm: InputMethodManager) { private fun onItemSelection(item: Music, imm: InputMethodManager) {
if (item is Song) { if (item is Song) {
playbackModel.playSong(item) playbackModel.playSong(item)

View file

@ -18,17 +18,17 @@
package org.oxycblt.auxio.search package org.oxycblt.auxio.search
import android.content.Context
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Header 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.music.MusicStore
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
@ -38,7 +38,7 @@ import java.text.Normalizer
* The [ViewModel] for the search functionality * The [ViewModel] for the search functionality
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback { class SearchViewModel : ViewModel(), MusicStore.MusicCallback {
private val mSearchResults = MutableLiveData(listOf<BaseModel>()) private val mSearchResults = MutableLiveData(listOf<BaseModel>())
private var mIsNavigating = false private var mIsNavigating = false
private var mFilterMode: DisplayMode? = null private var mFilterMode: DisplayMode? = null
@ -51,11 +51,6 @@ class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback
private val settingsManager = SettingsManager.getInstance() 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 { init {
mFilterMode = settingsManager.searchFilterMode mFilterMode = settingsManager.searchFilterMode
@ -83,28 +78,28 @@ class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
musicStore.artists.filterByOrNull(query)?.let { artists -> musicStore.artists.filterByOrNull(query)?.let { artists ->
results.add(artistHeader) results.add(Header(-1, HeaderString.Single(R.string.lbl_artists)))
results.addAll(artists) results.addAll(artists)
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
musicStore.albums.filterByOrNull(query)?.let { albums -> musicStore.albums.filterByOrNull(query)?.let { albums ->
results.add(albumHeader) results.add(Header(-1, HeaderString.Single(R.string.lbl_albums)))
results.addAll(albums) results.addAll(albums)
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
musicStore.genres.filterByOrNull(query)?.let { genres -> musicStore.genres.filterByOrNull(query)?.let { genres ->
results.add(genreHeader) results.add(Header(-1, HeaderString.Single(R.string.lbl_genres)))
results.addAll(genres) results.addAll(genres)
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
musicStore.songs.filterByOrNull(query)?.let { songs -> musicStore.songs.filterByOrNull(query)?.let { songs ->
results.add(songHeader) results.add(Header(-1, HeaderString.Single(R.string.lbl_songs)))
results.addAll(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 * Shortcut that will run a ignoreCase filter on a list and only return
* a value if the resulting list is empty. * 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 { val filtered = filter {
// First see if the normal item name will work. If that fails, try the "normalized" // 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 // [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() super.onCleared()
MusicStore.cancelAwaitInstance(this) 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
}
}
} }

View file

@ -22,8 +22,8 @@ import androidx.annotation.IdRes
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
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.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Parent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
/** /**
@ -44,7 +44,8 @@ enum class SortMode(@IdRes val itemId: Int) {
* Sort a list of songs. * Sort a list of songs.
* *
* **Behavior:** * **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. * - [ARTIST]: Grouped by album and then sorted [ASCENDING] based off the artist name.
* - [ALBUM]: Grouped by album and sorted [ASCENDING] * - [ALBUM]: Grouped by album and sorted [ASCENDING]
* - [YEAR]: Grouped by album and sorted by year * - [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> { fun sortSongs(songs: Collection<Song>): List<Song> {
return when (this) { 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 -> else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
ASCENDING.sortAlbum(album) ASCENDING.sortAlbum(album)
@ -66,7 +77,8 @@ enum class SortMode(@IdRes val itemId: Int) {
* Sort a list of albums. * Sort a list of albums.
* *
* **Behavior:** * **Behavior:**
* - [ASCENDING] & [DESCENDING]: See [sortModels] * - [ASCENDING]: By name after article, ascending
* - [DESCENDING]: By name after article, descending
* - [ARTIST]: Grouped by artist and sorted [ASCENDING] * - [ARTIST]: Grouped by artist and sorted [ASCENDING]
* - [ALBUM]: [ASCENDING] * - [ALBUM]: [ASCENDING]
* - [YEAR]: Sorted by year * - [YEAR]: Sorted by year
@ -75,43 +87,40 @@ enum class SortMode(@IdRes val itemId: Int) {
*/ */
fun sortAlbums(albums: Collection<Album>): List<Album> { fun sortAlbums(albums: Collection<Album>): List<Album> {
return when (this) { 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) } .flatMap { YEAR.sortAlbums(it.albums) }
ALBUM -> ASCENDING.sortModels(albums) ALBUM -> ASCENDING.sortParents(albums)
YEAR -> albums.sortedByDescending { it.year } YEAR -> albums.sortedByDescending { it.year }
} }
} }
/** /**
* Sort a list of generic [BaseModel] instances. * Sort a generic list of [Parent] instances.
* *
* **Behavior:** * **Behavior:**
* - [ASCENDING]: Sorted by name, ascending * - [ASCENDING]: By name after article, ascending
* - [DESCENDING]: Sorted by name, descending * - [DESCENDING]: By name after article, descending
* - Same list is returned otherwise. * - Same parent 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.
*/ */
fun <T : BaseModel> sortModels(models: Collection<T>): List<T> { fun <T : Parent> sortParents(parents: Collection<T>): List<T> {
return when (this) { return when (this) {
ASCENDING -> models.sortedWith( ASCENDING -> parents.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { model -> compareBy(String.CASE_INSENSITIVE_ORDER) { model ->
model.name.sliceArticle() model.resolvedName.sliceArticle()
} }
) )
DESCENDING -> models.sortedWith( DESCENDING -> parents.sortedWith(
compareByDescending(String.CASE_INSENSITIVE_ORDER) { model -> compareByDescending(String.CASE_INSENSITIVE_ORDER) { model ->
model.name.sliceArticle() model.resolvedName.sliceArticle()
} }
) )
else -> models.toList() else -> parents.toList()
} }
} }

View file

@ -80,7 +80,7 @@
android:layout_marginStart="@dimen/spacing_mid_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:layout_marginEnd="@dimen/spacing_mid_large" android:layout_marginEnd="@dimen/spacing_mid_large"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}" 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_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5" app:layout_constraintHorizontal_bias="0.5"

View file

@ -82,7 +82,7 @@
android:layout_marginStart="@dimen/spacing_mid_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:layout_marginEnd="@dimen/spacing_mid_large" android:layout_marginEnd="@dimen/spacing_mid_large"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}" 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_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5" app:layout_constraintHorizontal_bias="0.5"

View file

@ -70,7 +70,7 @@
android:layout_marginStart="@dimen/spacing_mid_huge" android:layout_marginStart="@dimen/spacing_mid_huge"
android:layout_marginEnd="@dimen/spacing_mid_huge" android:layout_marginEnd="@dimen/spacing_mid_huge"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}" 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_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View file

@ -58,7 +58,7 @@
android:layout_marginStart="@dimen/spacing_small" android:layout_marginStart="@dimen/spacing_small"
android:layout_marginEnd="@dimen/spacing_small" android:layout_marginEnd="@dimen/spacing_small"
android:ellipsize="end" 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_constraintBottom_toBottomOf="@+id/playback_cover"
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause" app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
app:layout_constraintStart_toEndOf="@+id/playback_cover" app:layout_constraintStart_toEndOf="@+id/playback_cover"

View file

@ -69,7 +69,7 @@
android:layout_marginStart="@dimen/spacing_mid_large" android:layout_marginStart="@dimen/spacing_mid_large"
android:layout_marginEnd="@dimen/spacing_mid_large" android:layout_marginEnd="@dimen/spacing_mid_large"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}" 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_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View file

@ -20,7 +20,7 @@
style="@style/Widget.Auxio.TextView.Header" style="@style/Widget.Auxio.TextView.Header"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{header.name}" android:text="@{header.string.resolve(context)}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -41,7 +41,7 @@
style="@style/Widget.Auxio.TextView.Item.Secondary" style="@style/Widget.Auxio.TextView.Item.Secondary"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover" app:layout_constraintStart_toEndOf="@+id/album_cover"

View file

@ -16,7 +16,7 @@
<ImageView <ImageView
android:id="@+id/artist_image" android:id="@+id/artist_image"
style="@style/Widget.Auxio.Image.Normal" 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:artistImage="@{artist}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -28,7 +28,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
style="@style/Widget.Auxio.TextView.Item.Primary" style="@style/Widget.Auxio.TextView.Item.Primary"
android:text="@{artist.name}" android:text="@{artist.resolvedName}"
app:layout_constraintBottom_toTopOf="@+id/artist_details" app:layout_constraintBottom_toTopOf="@+id/artist_details"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/artist_image" app:layout_constraintStart_toEndOf="@+id/artist_image"

View file

@ -16,7 +16,7 @@
<ImageView <ImageView
android:id="@+id/genre_image" android:id="@+id/genre_image"
style="@style/Widget.Auxio.Image.Normal" 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:genreImage="@{genre}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View file

@ -44,7 +44,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_duration" app:layout_constraintEnd_toStartOf="@+id/song_duration"
app:layout_constraintStart_toEndOf="@+id/album_cover" app:layout_constraintStart_toEndOf="@+id/album_cover"

View file

@ -15,6 +15,6 @@
style="@style/Widget.Auxio.TextView.Header" style="@style/Widget.Auxio.TextView.Header"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_width="match_parent" android:layout_width="match_parent"
android:text="@{header.name}" android:text="@{header.string.resolve(context)}"
tools:text="Songs" /> tools:text="Songs" />
</layout> </layout>

View file

@ -69,7 +69,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle" app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
app:layout_constraintStart_toEndOf="@+id/album_cover" app:layout_constraintStart_toEndOf="@+id/album_cover"

View file

@ -42,7 +42,7 @@
style="@style/Widget.Auxio.TextView.Item.Secondary" style="@style/Widget.Auxio.TextView.Item.Secondary"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover" app:layout_constraintStart_toEndOf="@+id/album_cover"

View file

@ -41,4 +41,9 @@
<dimen name="popup_min_width">78dp</dimen> <dimen name="popup_min_width">78dp</dimen>
<dimen name="popup_padding_end">28dp</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> </resources>

View file

@ -134,8 +134,8 @@
<string name="desc_artist_image">Artist Image for %s</string> <string name="desc_artist_image">Artist Image for %s</string>
<string name="desc_genre_image">Genre Image for %s</string> <string name="desc_genre_image">Genre Image for %s</string>
<!-- Default Namespace | Placeholder values --> <!-- Default Namespace | Placeholder values -->
<string name="def_artist">Unknown Artist</string>
<string name="def_genre">Unknown Genre</string> <string name="def_genre">Unknown Genre</string>
<string name="def_date">No Date</string> <string name="def_date">No Date</string>
<string name="def_playback">No music playing</string> <string name="def_playback">No music playing</string>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/info_widget_desc" android:description="@string/info_widget_desc"
android:initialLayout="@layout/widget_small" android:initialLayout="@layout/widget_minimal"
android:minResizeWidth="176dp" android:minResizeWidth="@dimen/widget_width_min"
android:minResizeHeight="152dp" android:minResizeHeight="@dimen/widget_height_min"
android:previewLayout="@layout/widget_small" android:previewLayout="@layout/widget_minimal"
android:previewImage="@drawable/ui_widget_preview" android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:minWidth="176dp" android:minWidth="@dimen/widget_width_def"
android:minHeight="180dp" android:minHeight="@dimen/widget_height_def"
android:targetCellWidth="3" android:targetCellWidth="3"
android:targetCellHeight="3" android:targetCellHeight="3"
android:updatePeriodMillis="0" android:updatePeriodMillis="0"

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_small" android:initialLayout="@layout/widget_minimal"
android:minWidth="176dp" android:minWidth="@dimen/widget_width_min"
android:minHeight="180dp" android:minHeight="@dimen/widget_height_min"
android:minResizeWidth="176dp" android:minResizeWidth="@dimen/widget_width_def"
android:minResizeHeight="152dp" android:minResizeHeight="@dimen/widget_height_def"
android:previewImage="@drawable/ui_widget_preview" android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0" android:updatePeriodMillis="0"