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))
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?,
savedInstanceState: Bundle?
): View {
detailModel.setAlbum(args.albumId, requireContext())
detailModel.setAlbum(args.albumId)
val detailAdapter = AlbumDetailAdapter(
playbackModel, detailModel,

View file

@ -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,

View file

@ -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) {

View file

@ -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 ->

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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() {

View file

@ -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()

View file

@ -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(

View file

@ -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(

View file

@ -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()

View file

@ -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

View file

@ -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 ->

View file

@ -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)
}

View file

@ -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 ---
/**

View file

@ -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.

View file

@ -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
}
}

View file

@ -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 ---

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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>

View file

@ -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"

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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"