diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bf8a7a6e..78196c0ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,15 @@
# Changelog
-## dev
+## 3.0.0
#### What's New
-- Added support for songs with multiple genres
-- Reworked music hashing to be even more reliable (Will wipe playback state)
+- Massively reworked music loading system:
+ - Auxio now supports multiple artists
+ - Auxio now supports multiple genres
+ - Artists and album artists are now both given equal importance in the UI
+ - Made music hashing rely on the more reliable MD5
+ - **This may impact your library.** Instructions on how to update your library to result in a good
+ artist experience will be added to the FAQ.
#### What's Improved
- Sorting now takes accented characters into account
@@ -17,9 +22,11 @@
- Fixed issue where the playback progress would continue in the notification even if
audio focus was lost
- Fixed issue where the app would crash if a song menu in the genre UI was opened
+- Fixed issue where the artist name would not be shown in the OS audio switcher menu
#### What's Changed
- Ignore MediaStore tags is now on by default
+- Removed the "Play from genre" option in the library/detail playback mode settings
#### Dev/Meta
- Completed migration to reactive playback system
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
index 775f620b2..7b574222c 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
@@ -120,7 +120,7 @@ class AlbumDetailFragment :
true
}
R.id.action_go_artist -> {
- navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artist)
+ onNavigateToArtist()
true
}
else -> false
@@ -132,16 +132,18 @@ class AlbumDetailFragment :
when (settings.detailPlaybackMode) {
null, MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
MusicMode.SONGS -> playbackModel.playFromAll(item)
- MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
- MusicMode.GENRES -> if (item.genres.size > 1) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
+ MusicMode.ARTISTS -> {
+ if (item.artists.size == 1) {
+ playbackModel.playFromArtist(item, item.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
+ )
)
- )
- } else {
- playbackModel.playFromGenre(item, item.genres[0])
+ }
}
+ else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
}
}
@@ -177,12 +179,16 @@ class AlbumDetailFragment :
}
override fun onNavigateToArtist() {
- findNavController()
- .navigate(
- AlbumDetailFragmentDirections.actionShowArtist(
- unlikelyToBeNull(detailModel.currentAlbum.value).artist.uid
+ val album = unlikelyToBeNull(detailModel.currentAlbum.value)
+ if (album.artists.size == 1) {
+ navModel.exploreNavigateTo(album.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(album.uid, PickerMode.SHOW)
)
)
+ }
}
private fun handleItemChange(album: Album?) {
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
index 0e913baca..7a3ccf8ed 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
@@ -122,18 +122,21 @@ class ArtistDetailFragment :
when (item) {
is Song -> {
when (settings.detailPlaybackMode) {
- null, MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
+ null -> playbackModel.playFromArtist(item, unlikelyToBeNull(detailModel.currentArtist.value))
MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
- MusicMode.GENRES -> if (item.genres.size > 1) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
+ MusicMode.ARTISTS -> {
+ if (item.artists.size == 1) {
+ playbackModel.playFromArtist(item, item.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
+ )
)
- )
- } else {
- playbackModel.playFromGenre(item, item.genres[0])
+ }
}
+ else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
}
}
is Album -> navModel.exploreNavigateTo(item)
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
index 60e00d535..aa46b4608 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
@@ -240,7 +240,7 @@ class DetailViewModel(application: Application) :
private fun refreshArtistData(artist: Artist) {
logD("Refreshing artist data")
val data = mutableListOf- (artist)
- val albums = Sort(Sort.Mode.ByYear, false).albums(artist.albums)
+ val albums = Sort(Sort.Mode.ByDate, false).albums(artist.albums)
val byReleaseGroup =
albums.groupBy {
@@ -265,8 +265,12 @@ class DetailViewModel(application: Application) :
data.addAll(entry.value)
}
- data.add(SortHeader(R.string.lbl_songs))
- data.addAll(artistSort.songs(artist.songs))
+ // Artists may not be linked to any songs, only include a header entry if we have any.
+ if (artist.songs.isNotEmpty()) {
+ data.add(SortHeader(R.string.lbl_songs))
+ data.addAll(artistSort.songs(artist.songs))
+ }
+
_artistData.value = data.toList()
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
index bdf32be36..08fb959e9 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
@@ -125,16 +125,18 @@ class GenreDetailFragment :
null -> playbackModel.playFromGenre(item, unlikelyToBeNull(detailModel.currentGenre.value))
MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
- MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
- MusicMode.GENRES -> if (item.genres.size > 1) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
+ MusicMode.ARTISTS -> {
+ if (item.artists.size == 1) {
+ playbackModel.playFromArtist(item, item.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
+ )
)
- )
- } else {
- playbackModel.playFromGenre(item, item.genres[0])
+ }
}
+ else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
index c3fd0f677..af2dba420 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
@@ -114,7 +114,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
binding.detailName.text = item.resolveName(binding.context)
binding.detailSubhead.apply {
- text = item.artist.resolveName(context)
+ text = item.resolveArtistContents(context)
setOnClickListener { listener.onNavigateToArtist() }
}
@@ -144,7 +144,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
object : SimpleItemCallback() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
- oldItem.artist.rawName == newItem.artist.rawName &&
+ oldItem.areArtistContentsTheSame(newItem) &&
oldItem.date == newItem.date &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs &&
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
index e9553baf8..3301028eb 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
@@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
@@ -27,10 +28,8 @@ import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveYear
-import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
@@ -110,26 +109,30 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = item.resolveName(binding.context)
- // Get the genre that corresponds to the most songs in this artist, which would be
- // the most "Prominent" genre.
- val genresByAmount = mutableMapOf()
- for (song in item.songs) {
- for (genre in song.genres) {
- genresByAmount[genre] = genresByAmount[genre]?.inc() ?: 1
+ if (item.songs.isNotEmpty()) {
+ binding.detailSubhead.apply {
+ isVisible = true
+ text = item.resolveGenreContents(binding.context)
}
+
+ binding.detailInfo.text =
+ binding.context.getString(
+ R.string.fmt_two,
+ binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size),
+ binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)
+ )
+
+ binding.detailPlayButton.isEnabled = true
+ binding.detailShuffleButton.isEnabled = true
+ } else {
+ // The artist is a
+ binding.detailSubhead.isVisible = false
+ binding.detailInfo.text =
+ binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size)
+ binding.detailPlayButton.isEnabled = false
+ binding.detailShuffleButton.isEnabled = false
}
- binding.detailSubhead.text =
- genresByAmount.maxByOrNull { it.value }?.key?.resolveName(binding.context)
- ?: binding.context.getString(R.string.def_genre)
-
- binding.detailInfo.text =
- binding.context.getString(
- R.string.fmt_two,
- binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size),
- binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)
- )
-
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
@@ -140,7 +143,13 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
fun new(parent: View) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
- val DIFFER = ArtistViewHolder.DIFFER
+ val DIFFER = object : SimpleItemCallback() {
+ override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
+ oldItem.rawName == newItem.rawName &&
+ oldItem.areGenreContentsTheSame(newItem) &&
+ oldItem.albums.size == newItem.albums.size &&
+ oldItem.songs.size == newItem.songs.size
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
index c09f569fb..cc9e6354b 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
@@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
@@ -95,9 +96,13 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
binding.detailCover.bind(item)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = item.resolveName(binding.context)
- binding.detailSubhead.text =
- binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)
- binding.detailInfo.text = item.durationMs.formatDurationMs(false)
+ binding.detailSubhead.isVisible = false
+ binding.detailInfo.text = binding.context.getString(
+ R.string.fmt_two,
+ binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size),
+ item.durationMs.formatDurationMs(false)
+ )
+
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
index 3ff5a0058..2d66e7642 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
@@ -66,11 +66,11 @@ class AlbumListFragment : HomeListFragment() {
// By Name -> Use Name
is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() }
- // By Artist -> Use Artist Name
- is Sort.Mode.ByArtist -> album.artist.collationKey?.run { sourceString.first().uppercase() }
+ // By Artist -> Use name of first artist
+ is Sort.Mode.ByArtist -> album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year
- is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext())
+ is Sort.Mode.ByDate -> album.date?.resolveYear(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
index ec77ca008..7bd2e948c 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
@@ -33,6 +33,7 @@ import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
import org.oxycblt.auxio.util.collectImmediately
+import org.oxycblt.auxio.util.nonZeroOrNull
/**
* A [HomeListFragment] for showing a list of [Artist]s.
@@ -62,10 +63,10 @@ class ArtistListFragment : HomeListFragment() {
is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() }
// Duration -> Use formatted duration
- is Sort.Mode.ByDuration -> artist.durationMs.formatDurationMs(false)
+ is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
// Count -> Use song count
- is Sort.Mode.ByCount -> artist.songs.size.toString()
+ is Sort.Mode.ByCount -> artist.songs.size.nonZeroOrNull()?.toString()
// Unsupported sort, error gracefully
else -> null
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
index b58fa0d2f..8ed2041be 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
@@ -79,14 +79,14 @@ class SongListFragment : HomeListFragment() {
// Name -> Use name
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() }
- // Artist -> Use Artist Name
- is Sort.Mode.ByArtist -> song.album.artist.collationKey?.run { sourceString.first().uppercase() }
+ // Artist -> Use name of first artist
+ is Sort.Mode.ByArtist -> song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year
- is Sort.Mode.ByYear -> song.album.date?.resolveYear(requireContext())
+ is Sort.Mode.ByDate -> song.album.date?.resolveYear(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
@@ -115,16 +115,18 @@ class SongListFragment : HomeListFragment() {
when (settings.libPlaybackMode) {
MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
- MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
- MusicMode.GENRES -> if (item.genres.size > 1) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
+ MusicMode.ARTISTS -> {
+ if (item.artists.size == 1) {
+ playbackModel.playFromArtist(item, item.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
+ )
)
- )
- } else {
- playbackModel.playFromGenre(item, item.genres[0])
+ }
}
+ else -> error("Unexpected playback mode: ${settings.libPlaybackMode}")
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
index c7ba9fddb..91f0c7c37 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
@@ -108,22 +108,8 @@ private constructor(
private val genre: Genre
) : BaseFetcher() {
override suspend fun fetch(): FetchResult? {
- // Genre logic is the most complicated, as we want to ensure album cover variation (i.e
- // all four covers shouldn't be from the same artist) while also still leveraging mosaics
- // whenever possible. So, if there are more than four distinct artists in a genre, make
- // it so that one artist only adds one album cover to the mosaic. Otherwise, use order
- // albums normally.
- val artists = genre.songs.groupBy { it.album.artist }.keys
- val albums =
- Sort(Sort.Mode.ByName, true).albums(genre.songs.groupBy { it.album }.keys).run {
- if (artists.size > 4) {
- distinctBy { it.artist.rawName }
- } else {
- this
- }
- }
+ val results = genre.albums.mapAtMost(4) { fetchArt(context, it) }
- val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
return createMosaic(context, results, size)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
index c06778399..80d301f94 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -23,15 +23,12 @@ import android.content.Context
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
-import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.music.Date.Companion.from
import org.oxycblt.auxio.music.extractor.parseId3GenreNames
import org.oxycblt.auxio.music.extractor.parseMultiValue
import org.oxycblt.auxio.music.extractor.parseReleaseType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.recycler.Item
-import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
import java.security.MessageDigest
@@ -39,7 +36,6 @@ import java.text.CollationKey
import java.text.Collator
import java.util.UUID
import kotlin.math.max
-import kotlin.math.min
// --- MUSIC MODELS ---
@@ -200,10 +196,6 @@ sealed class Music : Item {
sealed class MusicParent : Music() {
/** The songs that this parent owns. */
abstract val songs: List
-
- override fun _finalize() {
- check(songs.isNotEmpty()) { "Invalid parent: No songs" }
- }
}
/**
@@ -214,7 +206,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
override val uid = UID.hashed(MusicMode.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
- // same standard since grouping is directly linked to settings.
+ // same standard since grouping is already inherently linked to settings.
update(raw.name)
update(raw.albumName)
update(raw.date)
@@ -274,41 +266,63 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
private var _album: Album? = null
- /** The album of this song. */
+ /**
+ * The album of this song. Every song is guaranteed to have one and only one album,
+ * with a "directory" album being used if no album tag can be found.
+ */
val album: Album
get() = unlikelyToBeNull(_album)
- // TODO: Multi-artist support
- // private val _artists: MutableList = mutableListOf()
+ private val artistNames = raw.artistNames.parseMultiValue(settings)
- private val artistName = raw.artistNames.parseMultiValue(settings)
- .joinToString().ifEmpty { null }
+ private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings)
- private val albumArtistName = raw.albumArtistNames.parseMultiValue(settings)
- .joinToString().ifEmpty { null }
+ private val artistSortNames = raw.artistSortNames.parseMultiValue(settings)
- private val artistSortName = raw.artistSortNames.parseMultiValue(settings)
- .joinToString().ifEmpty { null }
+ private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings)
- private val albumArtistSortName = raw.albumArtistSortNames.parseMultiValue(settings)
- .joinToString().ifEmpty { null }
-
- /**
- * Resolve the artist name for this song in particular. First uses the artist tag, and then
- * falls back to the album artist tag (i.e parent artist name)
- */
- fun resolveIndividualArtistName(context: Context) =
- artistName ?: album.artist.resolveName(context)
-
- fun areArtistContentsTheSame(other: Song): Boolean {
- if (other.artistName != null && artistName != null) {
- return other.artistName == artistName
- }
-
- return album.artist.rawName == other.album.artist.rawName
+ private val rawArtists = artistNames.mapIndexed { i, name ->
+ Artist.Raw(name, artistSortNames.getOrNull(i))
}
- private val _genres: MutableList = mutableListOf()
+ private val rawAlbumArtists = albumArtistNames.mapIndexed { i, name ->
+ Artist.Raw(name, albumArtistSortNames.getOrNull(i))
+ }
+
+ private val _artists = mutableListOf()
+
+ /**
+ * The artists of this song. Most often one, but there could be multiple. These artists
+ * are derived from the artists tag and not the album artists tag, so they may differ from
+ * the artists of the album.
+ */
+ val artists: List
+ get() = _artists
+
+ /**
+ * Resolve the artists of this song into a human-readable name. First tries to use artist
+ * tags, then falls back to album artist tags.
+ */
+ fun resolveArtistContents(context: Context) =
+ artists.joinToString { it.resolveName(context) }
+
+ /**
+ * Utility method for recyclerview diffing that checks if resolveArtistContents is the
+ * same without a context.
+ */
+ fun areArtistContentsTheSame(other: Song): Boolean {
+ for (i in 0 until max(artists.size, other.artists.size)) {
+ val a = artists.getOrNull(i) ?: return false
+ val b = other.artists.getOrNull(i) ?: return false
+ if (a.rawName != b.rawName) {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ private val _genres = mutableListOf()
/**
* The genres of this song. Most often one, but there could be multiple. There will always be at
@@ -317,36 +331,47 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
val genres: List
get() = _genres
+ /**
+ * Resolve the genres of the song into a human-readable string.
+ */
+ fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
+
// --- INTERNAL FIELDS ---
+ val _rawGenres = raw.genreNames.parseId3GenreNames(settings)
+ .map { Genre.Raw(it) }.ifEmpty { listOf(Genre.Raw(null)) }
+
+ val _rawArtists = rawArtists.ifEmpty { rawAlbumArtists }.ifEmpty {
+ listOf(Artist.Raw(null, null))
+ }
+
val _rawAlbum =
Album.Raw(
mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" },
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
sortName = raw.albumSortName,
releaseType = raw.albumReleaseType.parseReleaseType(settings),
- rawArtist =
- if (albumArtistName != null) {
- Artist.Raw(albumArtistName, albumArtistSortName)
- } else {
- Artist.Raw(artistName, artistSortName)
- }
+ rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }
)
- val _rawGenres = raw.genreNames.parseId3GenreNames(settings)
- .map { Genre.Raw(it) }.ifEmpty { listOf(Genre.Raw(null)) }
-
fun _link(album: Album) {
_album = album
}
+ fun _link(artist: Artist) {
+ _artists.add(artist)
+ }
+
fun _link(genre: Genre) {
_genres.add(genre)
}
override fun _finalize() {
- (checkNotNull(_album) { "Malformed song: Album is null" })
+ checkNotNull(_album) { "Malformed song: No album" }
+ check(_artists.isNotEmpty()) { "Malformed song: No artists" }
+ Sort(Sort.Mode.ByName, true).artistsInPlace(_artists)
check(_genres.isNotEmpty()) { "Malformed song: No genres" }
+ Sort(Sort.Mode.ByName, true).genresInPlace(_genres)
}
class Raw
@@ -387,7 +412,7 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent(
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(raw.name)
- update(raw.rawArtist.name)
+ update(raw.rawArtists.map { it.name })
}
override val rawName = raw.name
@@ -416,23 +441,33 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent(
/** The earliest date a song in this album was added. */
val dateAdded: Long
- private var _artist: Artist? = null
+ /**
+ * The artists of this album. Usually one, but there may be more. These are derived from
+ * the album artist first, so they may differ from the song artists.
+ */
+ private val _artists = mutableListOf()
+ val artists: List get() = _artists
- /** The parent artist of this album. */
- val artist: Artist
- get() = unlikelyToBeNull(_artist)
+ /**
+ * Resolve the artists of this album in a human-readable manner.
+ */
+ fun resolveArtistContents(context: Context) =
+ artists.joinToString { it.resolveName(context) }
- // --- INTERNAL FIELDS ---
+ /**
+ * Utility for RecyclerView differs to check if resolveArtistContents is the same without
+ * a context.
+ */
+ fun areArtistContentsTheSame(other: Album): Boolean {
+ for (i in 0 until max(artists.size, other.artists.size)) {
+ val a = artists.getOrNull(i) ?: return false
+ val b = other.artists.getOrNull(i) ?: return false
+ if (a.rawName != b.rawName) {
+ return false
+ }
+ }
- val _rawArtist = raw.rawArtist
-
- fun _link(artist: Artist) {
- _artist = artist
- }
-
- override fun _finalize() {
- super._finalize()
- checkNotNull(_artist) { "Invalid album: Artist is null " }
+ return true
}
init {
@@ -462,32 +497,43 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent(
dateAdded = earliestDateAdded
}
+ // --- INTERNAL FIELDS ---
+
+ val _rawArtists = raw.rawArtists
+
+ fun _link(artist: Artist) {
+ _artists.add(artist)
+ }
+
+ override fun _finalize() {
+ check(songs.isNotEmpty()) { "Malformed album: Empty" }
+ check(_artists.isNotEmpty()) { "Malformed album: No artists" }
+ Sort(Sort.Mode.ByName, true).artistsInPlace(_artists)
+ }
+
class Raw(
val mediaStoreId: Long,
val name: String,
val sortName: String?,
val releaseType: ReleaseType?,
- val rawArtist: Artist.Raw
+ val rawArtists: List
) {
- private val hashCode = 31 * name.lowercase().hashCode() + rawArtist.hashCode()
+ private val hashCode = 31 * name.lowercase().hashCode() + rawArtists.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
- other is Raw && name.equals(other.name, true) && rawArtist == other.rawArtist
+ other is Raw && name.equals(other.name, true) && rawArtists == other.rawArtists
}
}
/**
- * An artist. This is derived from the album artist first, and then the normal artist second.
+ * An abstract artist. This is derived from both album artist values and artist values in
+ * albums and songs respectively.
* @author OxygenCobalt
*/
class Artist
-constructor(
- raw: Raw,
- /** The albums of this artist. */
- val albums: List
-) : MusicParent() {
+constructor(raw: Raw, songAlbums: List) : MusicParent() {
override val uid = UID.hashed(MusicMode.ARTISTS) { update(raw.name) }
override val rawName = raw.name
@@ -498,22 +544,71 @@ constructor(
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
- private val _songs = mutableListOf()
- override val songs = _songs
+ /**
+ * The songs of this artist. This might be empty.
+ */
+ override val songs: List
- /** The total duration of songs in this artist, in millis. */
- val durationMs: Long
+ /** The total duration of songs in this artist, in millis. Null if there are no songs. */
+ val durationMs: Long?
- init {
- var totalDuration = 0L
+ /** The albums of this artist. This will never be empty. */
+ val albums: List
- for (album in albums) {
- album._link(this)
- _songs.addAll(album.songs)
- totalDuration += album.durationMs
+ private lateinit var genres: List
+
+ /**
+ * Resolve the combined genres of this artist into a human-readable string.
+ */
+ fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
+
+ /**
+ * Utility for RecyclerView differs to check if resolveGenreContents is the same without
+ * a context.
+ */
+ fun areGenreContentsTheSame(other: Artist): Boolean {
+ for (i in 0 until max(genres.size, other.genres.size)) {
+ val a = genres.getOrNull(i) ?: return false
+ val b = other.genres.getOrNull(i) ?: return false
+ if (a.rawName != b.rawName) {
+ return false
+ }
}
- durationMs = totalDuration
+ return true
+ }
+
+ init {
+ val distinctSongs = mutableSetOf()
+ val distinctAlbums = mutableSetOf()
+
+ for (music in songAlbums) {
+ when (music) {
+ is Song -> {
+ music._link(this)
+ distinctSongs.add(music)
+ distinctAlbums.add(music.album)
+ }
+
+ is Album -> {
+ music._link(this)
+ distinctAlbums.add(music)
+ }
+
+ else -> error("Unexpected input music ${music::class.simpleName}")
+ }
+ }
+
+ songs = distinctSongs.toList()
+ albums = distinctAlbums.toList()
+ durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
+ }
+
+ override fun _finalize() {
+ check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" }
+
+ genres = Sort(Sort.Mode.ByName, true).genres(songs.flatMapTo(mutableSetOf()) { it.genres })
+ .sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
}
class Raw(val name: String?, val sortName: String?) {
@@ -550,15 +645,29 @@ class Genre constructor(raw: Raw, override val songs: List) : MusicParent(
/** The total duration of the songs in this genre, in millis. */
val durationMs: Long
+ /** The albums of this genre. */
+ val albums: List
+
init {
var totalDuration = 0L
+ val distinctAlbums = mutableSetOf()
for (song in songs) {
song._link(this)
+ distinctAlbums.add(song.album)
totalDuration += song.durationMs
}
durationMs = totalDuration
+
+ albums = Sort(Sort.Mode.ByName, true).albums(distinctAlbums)
+ .sortedByDescending { album ->
+ album.songs.count { it.genres.contains(this) }
+ }
+ }
+
+ override fun _finalize() {
+ check(songs.isNotEmpty()) { "Malformed genre: Empty" }
}
class Raw(val name: String?) {
@@ -591,7 +700,7 @@ fun MessageDigest.update(date: Date?) {
}
/** Update the digest using a list of strings. */
-fun MessageDigest.update(strings: List) {
+fun MessageDigest.update(strings: List) {
strings.forEach(::update)
}
@@ -656,263 +765,3 @@ fun ByteArray.toUuid(): UUID {
.or(get(15).toLong().and(0xFF))
)
}
-
-/**
- * An ISO-8601/RFC 3339 Date.
- *
- * Unlike a typical Date within the standard library, this class just represents the ID3v2/Vorbis
- * date format, which is largely assumed to be a subset of ISO-8601. No validation outside of format
- * validation is done.
- *
- * The reasoning behind Date is that Auxio cannot trust any kind of metadata date to actually make
- * sense in a calendar, due to bad tagging, locale-specific issues, or simply from the limited
- * nature of tag formats. Thus, it's better to use an analogous data structure that will not mangle
- * or reject valid-ish dates.
- *
- * Date instances are immutable and their implementation is hidden. To instantiate one, use [from].
- * The string representation of a Date is RFC 3339, with granular position depending on the presence
- * of particular tokens.
- *
- * Please, **Do not use this for anything important related to time.** I cannot stress this enough.
- * This code will blow up if you try to do that.
- *
- * @author OxygenCobalt
- */
-class Date private constructor(private val tokens: List) : Comparable {
- init {
- if (BuildConfig.DEBUG) {
- // Last-ditch sanity check to catch format bugs that might slip through
- check(tokens.size in 1..6) { "There must be 1-6 date tokens" }
- check(tokens.slice(0..min(tokens.lastIndex, 2)).all { it > 0 }) {
- "All date tokens must be non-zero "
- }
- check(tokens.slice(1..tokens.lastIndex).all { it < 100 }) {
- "All non-year tokens must be two digits"
- }
- }
- }
-
- val year = tokens[0]
-
- /** Resolve the year field in a way suitable for the UI. */
- fun resolveYear(context: Context) = context.getString(R.string.fmt_number, year)
-
- private val month = tokens.getOrNull(1)
-
- private val day = tokens.getOrNull(2)
-
- private val hour = tokens.getOrNull(3)
-
- private val minute = tokens.getOrNull(4)
-
- private val second = tokens.getOrNull(5)
-
- override fun hashCode() = tokens.hashCode()
-
- override fun equals(other: Any?) = other is Date && tokens == other.tokens
-
- override fun compareTo(other: Date): Int {
- val comparator = Sort.Mode.NullableComparator.INT
-
- for (i in 0..(max(tokens.lastIndex, other.tokens.lastIndex))) {
- val result = comparator.compare(tokens.getOrNull(i), other.tokens.getOrNull(i))
- if (result != 0) {
- return result
- }
- }
-
- return 0
- }
-
- override fun toString() = StringBuilder().appendDate().toString()
-
- private fun StringBuilder.appendDate(): StringBuilder {
- append(year.toFixedString(4))
- append("-${(month ?: return this).toFixedString(2)}")
- append("-${(day ?: return this).toFixedString(2)}")
- append("T${(hour ?: return this).toFixedString(2)}")
- append(":${(minute ?: return this.append('Z')).toFixedString(2)}")
- append(":${(second ?: return this.append('Z')).toFixedString(2)}")
- return this.append('Z')
- }
-
- private fun Int.toFixedString(len: Int) = toString().padStart(len, '0')
-
- companion object {
- private val ISO8601_REGEX =
- Regex(
- """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2}))?)?)?)?)?$"""
- )
-
- fun from(year: Int) = fromTokens(listOf(year))
-
- fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
-
- fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
- fromTokens(listOf(year, month, day, hour, minute))
-
- fun from(timestamp: String): Date? {
- val groups =
- (ISO8601_REGEX.matchEntire(timestamp) ?: return null)
- .groupValues
- .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
-
- return fromTokens(groups)
- }
-
- private fun fromTokens(tokens: List): Date? {
- val out = mutableListOf()
- validateTokens(tokens, out)
- if (out.isEmpty()) {
- return null
- }
-
- return Date(out)
- }
-
- private fun validateTokens(src: List, dst: MutableList) {
- dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
- dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
- dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
- dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
- dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
- dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return)
- }
- }
-}
-
-/**
- * Represents the type of release a particular album is.
- *
- * This can be used to differentiate between album sub-types like Singles, EPs, Compilations, and
- * others. Internally, it operates on a reduced version of the MusicBrainz release type
- * specification. It can be extended if there is demand.
- *
- * @author OxygenCobalt
- */
-sealed class ReleaseType {
- abstract val refinement: Refinement?
- abstract val stringRes: Int
-
- data class Album(override val refinement: Refinement?) : ReleaseType() {
- override val stringRes: Int
- get() =
- when (refinement) {
- null -> R.string.lbl_album
- Refinement.LIVE -> R.string.lbl_album_live
- Refinement.REMIX -> R.string.lbl_album_remix
- }
- }
-
- data class EP(override val refinement: Refinement?) : ReleaseType() {
- override val stringRes: Int
- get() =
- when (refinement) {
- null -> R.string.lbl_ep
- Refinement.LIVE -> R.string.lbl_ep_live
- Refinement.REMIX -> R.string.lbl_ep_remix
- }
- }
-
- data class Single(override val refinement: Refinement?) : ReleaseType() {
- override val stringRes: Int
- get() =
- when (refinement) {
- null -> R.string.lbl_single
- Refinement.LIVE -> R.string.lbl_single_live
- Refinement.REMIX -> R.string.lbl_single_remix
- }
- }
-
- data class Compilation(override val refinement: Refinement?) : ReleaseType() {
- override val stringRes: Int
- get() = when (refinement) {
- null -> R.string.lbl_compilation
- Refinement.LIVE -> R.string.lbl_compilation_live
- Refinement.REMIX -> R.string.lbl_compilation_remix
- }
- }
-
- object Soundtrack : ReleaseType() {
- override val refinement: Refinement?
- get() = null
-
- override val stringRes: Int
- get() = R.string.lbl_soundtrack
- }
-
- object Mix : ReleaseType() {
- override val refinement: Refinement?
- get() = null
-
- override val stringRes: Int
- get() = R.string.lbl_mix
- }
-
- object Mixtape : ReleaseType() {
- override val refinement: Refinement?
- get() = null
-
- override val stringRes: Int
- get() = R.string.lbl_mixtape
- }
-
- /**
- * Roughly analogous to the MusicBrainz "live" and "remix" secondary types. Unlike the main
- * types, these only modify an existing, primary type. They are not implemented for secondary
- * types, however they may be expanded to compilations in the future.
- */
- enum class Refinement {
- LIVE,
- REMIX
- }
-
- companion object {
- // Note: The parsing code is extremely clever in order to reduce duplication. It's
- // better just to read the specification behind release types than follow this code.
-
- fun parse(types: List): ReleaseType? {
- val primary = types.getOrNull(0) ?: return null
-
- // Primary types should be the first one in sequence. The spec makes no mention of
- // whether primary types are a pre-requisite for secondary types, so we assume that
- // it isn't. There are technically two other types, but those are unrelated to music
- // and thus we don't support them.
- return when {
- primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) }
- primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) }
- primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) }
- else -> types.parseSecondaryTypes(0) { Album(it) }
- }
- }
-
- private inline fun List.parseSecondaryTypes(
- secondaryIdx: Int,
- convertRefinement: (Refinement?) -> ReleaseType
- ): ReleaseType {
- val secondary = getOrNull(secondaryIdx)
-
- return if (secondary.equals("compilation", true)) {
- // Secondary type is a compilation, actually parse the third type
- // and put that into a compilation if needed.
- parseSecondaryTypeImpl(getOrNull(secondaryIdx + 1)) { Compilation(it) }
- } else {
- // Secondary type is a plain value, use the original values given.
- parseSecondaryTypeImpl(secondary, convertRefinement)
- }
- }
-
- private inline fun parseSecondaryTypeImpl(
- type: String?,
- convertRefinement: (Refinement?) -> ReleaseType
- ) = when {
- // Parse all the types that have no children
- type.equals("soundtrack", true) -> Soundtrack
- type.equals("mixtape/street", true) -> Mixtape
- type.equals("dj-mix", true) -> Mix
- type.equals("live", true) -> convertRefinement(Refinement.LIVE)
- type.equals("remix", true) -> convertRefinement(Refinement.REMIX)
- else -> convertRefinement(null)
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
index 1b0644598..02300b9bc 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
@@ -102,7 +102,7 @@ class MusicStore private constructor() {
* not [T], null will be returned.
*/
@Suppress("UNCHECKED_CAST")
- fun find(uid: Music.UID): T? = uidMap[uid] as? T
+ fun find(uid: Music.UID) = uidMap[uid] as? T
/** Sanitize an old item to find the corresponding item in a new library. */
fun sanitize(song: Song) = find(song.uid)
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt
index 35ba7c4c2..281751b76 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt
@@ -21,13 +21,14 @@ import androidx.annotation.IdRes
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Sort.Mode
+import kotlin.math.max
/**
* Represents the sort modes used in Auxio.
*
* Sorting can be done by Name, Artist, Album, and others. Sorting of names is always
* case-insensitive and article-aware. Certain datatypes may only support a subset of sorts since
- * certain sorts cannot be easily applied to them (For Example, [Mode.ByArtist] and [Mode.ByYear] or
+ * certain sorts cannot be easily applied to them (For Example, [Mode.ByArtist] and [Mode.ByDate] or
* [Mode.ByAlbum]).
*
* Internally, sorts are saved as an integer in the following format
@@ -78,11 +79,11 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
albums.sortWith(mode.getAlbumComparator(isAscending))
}
- private fun artistsInPlace(artists: MutableList) {
+ fun artistsInPlace(artists: MutableList) {
artists.sortWith(mode.getArtistComparator(isAscending))
}
- private fun genresInPlace(genres: MutableList) {
+ fun genresInPlace(genres: MutableList) {
genres.sortWith(mode.getGenreComparator(isAscending))
}
@@ -154,7 +155,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator =
MultiComparator(
- compareByDynamic(ascending, BasicComparator.ARTIST) { it.album.artist },
+ compareByDynamic(ascending, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE) { it.album.date },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc },
@@ -164,14 +165,14 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getAlbumComparator(ascending: Boolean): Comparator =
MultiComparator(
- compareByDynamic(ascending, BasicComparator.ARTIST) { it.artist },
+ compareByDynamic(ascending, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE) { it.date },
compareBy(BasicComparator.ALBUM)
)
}
- /** Sort by the year of an item, only supported by [Album] and [Song] */
- object ByYear : Mode() {
+ /** Sort by the date of an item, only supported by [Album] and [Song] */
+ object ByDate : Mode() {
override val intCode: Int
get() = IntegerTable.SORT_BY_YEAR
@@ -216,7 +217,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getArtistComparator(ascending: Boolean): Comparator =
MultiComparator(
- compareByDynamic(ascending) { it.durationMs },
+ compareByDynamic(ascending, NullableComparator.LONG) { it.durationMs },
compareBy(BasicComparator.ARTIST)
)
@@ -243,7 +244,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getArtistComparator(ascending: Boolean): Comparator =
MultiComparator(
- compareByDynamic(ascending) { it.songs.size },
+ compareByDynamic(ascending, NullableComparator.INT) { it.songs.size },
compareBy(BasicComparator.ARTIST)
)
@@ -362,6 +363,32 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
}
}
+ private class ListComparator(private val inner: Comparator) : Comparator
> {
+ override fun compare(a: List, b: List): Int {
+ for (i in 0 until max(a.size, b.size)) {
+ val ai = a.getOrNull(i)
+ val bi = b.getOrNull(i)
+ when {
+ ai != null && bi != null -> {
+ val result = inner.compare(ai, bi)
+ if (result != 0) {
+ return result
+ }
+ }
+ ai == null && bi != null -> return -1 // a < b
+ ai == null && bi == null -> return 0 // a = b
+ else -> return 1 // a < b
+ }
+ }
+
+ return 0
+ }
+
+ companion object {
+ val ARTISTS: Comparator> = ListComparator(BasicComparator.ARTIST)
+ }
+ }
+
private class BasicComparator private constructor() : Comparator {
override fun compare(a: T, b: T): Int {
val aKey = a.collationKey
@@ -382,7 +409,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
}
}
- class NullableComparator> private constructor() : Comparator {
+ private class NullableComparator> private constructor() : Comparator {
override fun compare(a: T?, b: T?) =
when {
a != null && b != null -> a.compareTo(b)
@@ -393,6 +420,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
companion object {
val INT = NullableComparator()
+ val LONG = NullableComparator()
val DATE = NullableComparator()
}
}
@@ -403,7 +431,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
ByName.itemId -> ByName
ByAlbum.itemId -> ByAlbum
ByArtist.itemId -> ByArtist
- ByYear.itemId -> ByYear
+ ByDate.itemId -> ByDate
ByDuration.itemId -> ByDuration
ByCount.itemId -> ByCount
ByDisc.itemId -> ByDisc
@@ -428,7 +456,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
Mode.ByName.intCode -> Mode.ByName
Mode.ByArtist.intCode -> Mode.ByArtist
Mode.ByAlbum.intCode -> Mode.ByAlbum
- Mode.ByYear.intCode -> Mode.ByYear
+ Mode.ByDate.intCode -> Mode.ByDate
Mode.ByDuration.intCode -> Mode.ByDuration
Mode.ByCount.intCode -> Mode.ByCount
Mode.ByDisc.intCode -> Mode.ByDisc
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Tags.kt b/app/src/main/java/org/oxycblt/auxio/music/Tags.kt
new file mode 100644
index 000000000..cdb89d392
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/Tags.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music
+
+import android.content.Context
+import org.oxycblt.auxio.BuildConfig
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.util.inRangeOrNull
+import org.oxycblt.auxio.util.nonZeroOrNull
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * An ISO-8601/RFC 3339 Date.
+ *
+ * Unlike a typical Date within the standard library, this class just represents the ID3v2/Vorbis
+ * date format, which is largely assumed to be a subset of ISO-8601. No validation outside of format
+ * validation is done.
+ *
+ * The reasoning behind Date is that Auxio cannot trust any kind of metadata date to actually make
+ * sense in a calendar, due to bad tagging, locale-specific issues, or simply from the limited
+ * nature of tag formats. Thus, it's better to use an analogous data structure that will not mangle
+ * or reject valid-ish dates.
+ *
+ * Date instances are immutable and their implementation is hidden. To instantiate one, use [from].
+ * The string representation of a Date is RFC 3339, with granular position depending on the presence
+ * of particular tokens.
+ *
+ * Please, **Do not use this for anything important related to time.** I cannot stress this enough.
+ * This code will blow up if you try to do that.
+ *
+ * @author OxygenCobalt
+ */
+class Date private constructor(private val tokens: List) : Comparable {
+ init {
+ if (BuildConfig.DEBUG) {
+ // Last-ditch sanity check to catch format bugs that might slip through
+ check(tokens.size in 1..6) { "There must be 1-6 date tokens" }
+ check(tokens.slice(0..min(tokens.lastIndex, 2)).all { it > 0 }) {
+ "All date tokens must be non-zero "
+ }
+ check(tokens.slice(1..tokens.lastIndex).all { it < 100 }) {
+ "All non-year tokens must be two digits"
+ }
+ }
+ }
+
+ val year = tokens[0]
+
+ /** Resolve the year field in a way suitable for the UI. */
+ fun resolveYear(context: Context) = context.getString(R.string.fmt_number, year)
+
+ private val month = tokens.getOrNull(1)
+
+ private val day = tokens.getOrNull(2)
+
+ private val hour = tokens.getOrNull(3)
+
+ private val minute = tokens.getOrNull(4)
+
+ private val second = tokens.getOrNull(5)
+
+ override fun hashCode() = tokens.hashCode()
+
+ override fun equals(other: Any?) = other is Date && tokens == other.tokens
+
+ override fun compareTo(other: Date): Int {
+ for (i in 0 until max(tokens.size, other.tokens.size)) {
+ val ai = tokens.getOrNull(i)
+ val bi = other.tokens.getOrNull(i)
+ when {
+ ai != null && bi != null -> {
+ val result = ai.compareTo(bi)
+ if (result != 0) {
+ return result
+ }
+ }
+ ai == null && bi != null -> return -1 // a < b
+ ai == null && bi == null -> return 0 // a = b
+ else -> return 1 // a < b
+ }
+ }
+
+ return 0
+ }
+
+ override fun toString() = StringBuilder().appendDate().toString()
+
+ private fun StringBuilder.appendDate(): StringBuilder {
+ append(year.toFixedString(4))
+ append("-${(month ?: return this).toFixedString(2)}")
+ append("-${(day ?: return this).toFixedString(2)}")
+ append("T${(hour ?: return this).toFixedString(2)}")
+ append(":${(minute ?: return this.append('Z')).toFixedString(2)}")
+ append(":${(second ?: return this.append('Z')).toFixedString(2)}")
+ return this.append('Z')
+ }
+
+ private fun Int.toFixedString(len: Int) = toString().padStart(len, '0')
+
+ companion object {
+ private val ISO8601_REGEX =
+ Regex(
+ """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2}))?)?)?)?)?$"""
+ )
+
+ fun from(year: Int) = fromTokens(listOf(year))
+
+ fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
+
+ fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
+ fromTokens(listOf(year, month, day, hour, minute))
+
+ fun from(timestamp: String): Date? {
+ val groups =
+ (ISO8601_REGEX.matchEntire(timestamp) ?: return null)
+ .groupValues
+ .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
+
+ return fromTokens(groups)
+ }
+
+ private fun fromTokens(tokens: List): Date? {
+ val out = mutableListOf()
+ validateTokens(tokens, out)
+ if (out.isEmpty()) {
+ return null
+ }
+
+ return Date(out)
+ }
+
+ private fun validateTokens(src: List, dst: MutableList) {
+ dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
+ dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
+ dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
+ dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
+ dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
+ dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return)
+ }
+ }
+}
+
+/**
+ * Represents the type of release a particular album is.
+ *
+ * This can be used to differentiate between album sub-types like Singles, EPs, Compilations, and
+ * others. Internally, it operates on a reduced version of the MusicBrainz release type
+ * specification. It can be extended if there is demand.
+ *
+ * @author OxygenCobalt
+ */
+sealed class ReleaseType {
+ abstract val refinement: Refinement?
+ abstract val stringRes: Int
+
+ data class Album(override val refinement: Refinement?) : ReleaseType() {
+ override val stringRes: Int
+ get() =
+ when (refinement) {
+ null -> R.string.lbl_album
+ Refinement.LIVE -> R.string.lbl_album_live
+ Refinement.REMIX -> R.string.lbl_album_remix
+ }
+ }
+
+ data class EP(override val refinement: Refinement?) : ReleaseType() {
+ override val stringRes: Int
+ get() =
+ when (refinement) {
+ null -> R.string.lbl_ep
+ Refinement.LIVE -> R.string.lbl_ep_live
+ Refinement.REMIX -> R.string.lbl_ep_remix
+ }
+ }
+
+ data class Single(override val refinement: Refinement?) : ReleaseType() {
+ override val stringRes: Int
+ get() =
+ when (refinement) {
+ null -> R.string.lbl_single
+ Refinement.LIVE -> R.string.lbl_single_live
+ Refinement.REMIX -> R.string.lbl_single_remix
+ }
+ }
+
+ data class Compilation(override val refinement: Refinement?) : ReleaseType() {
+ override val stringRes: Int
+ get() = when (refinement) {
+ null -> R.string.lbl_compilation
+ Refinement.LIVE -> R.string.lbl_compilation_live
+ Refinement.REMIX -> R.string.lbl_compilation_remix
+ }
+ }
+
+ object Soundtrack : ReleaseType() {
+ override val refinement: Refinement?
+ get() = null
+
+ override val stringRes: Int
+ get() = R.string.lbl_soundtrack
+ }
+
+ object Mix : ReleaseType() {
+ override val refinement: Refinement?
+ get() = null
+
+ override val stringRes: Int
+ get() = R.string.lbl_mix
+ }
+
+ object Mixtape : ReleaseType() {
+ override val refinement: Refinement?
+ get() = null
+
+ override val stringRes: Int
+ get() = R.string.lbl_mixtape
+ }
+
+ /**
+ * Roughly analogous to the MusicBrainz "live" and "remix" secondary types. Unlike the main
+ * types, these only modify an existing, primary type. They are not implemented for secondary
+ * types, however they may be expanded to compilations in the future.
+ */
+ enum class Refinement {
+ LIVE,
+ REMIX
+ }
+
+ companion object {
+ // Note: The parsing code is extremely clever in order to reduce duplication. It's
+ // better just to read the specification behind release types than follow this code.
+
+ fun parse(types: List): ReleaseType? {
+ val primary = types.getOrNull(0) ?: return null
+
+ // Primary types should be the first one in sequence. The spec makes no mention of
+ // whether primary types are a pre-requisite for secondary types, so we assume that
+ // it isn't. There are technically two other types, but those are unrelated to music
+ // and thus we don't support them.
+ return when {
+ primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) }
+ primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) }
+ primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) }
+ else -> types.parseSecondaryTypes(0) { Album(it) }
+ }
+ }
+
+ private inline fun List.parseSecondaryTypes(
+ secondaryIdx: Int,
+ convertRefinement: (Refinement?) -> ReleaseType
+ ): ReleaseType {
+ val secondary = getOrNull(secondaryIdx)
+
+ return if (secondary.equals("compilation", true)) {
+ // Secondary type is a compilation, actually parse the third type
+ // and put that into a compilation if needed.
+ parseSecondaryTypeImpl(getOrNull(secondaryIdx + 1)) { Compilation(it) }
+ } else {
+ // Secondary type is a plain value, use the original values given.
+ parseSecondaryTypeImpl(secondary, convertRefinement)
+ }
+ }
+
+ private inline fun parseSecondaryTypeImpl(
+ type: String?,
+ convertRefinement: (Refinement?) -> ReleaseType
+ ) = when {
+ // Parse all the types that have no children
+ type.equals("soundtrack", true) -> Soundtrack
+ type.equals("mixtape/street", true) -> Mixtape
+ type.equals("dj-mix", true) -> Mix
+ type.equals("live", true) -> convertRefinement(Refinement.LIVE)
+ type.equals("remix", true) -> convertRefinement(Refinement.REMIX)
+ else -> convertRefinement(null)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
index 6e61913b8..1e0cb50ee 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
@@ -224,7 +224,7 @@ class Task(context: Context, private val raw: Song.Raw) {
tags["TSOA"]?.let { raw.albumSortName = it[0] }
// (Sort) Artist
- tags["TPE1"]?.let { raw.artistNames = it }
+ (tags["TXXX:ARTISTS"] ?: tags["TPE1"])?.let { raw.artistNames = it }
tags["TSOP"]?.let { raw.artistSortNames = it }
// (Sort) Album artist
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt
index 78d8c0ed9..cefa9f327 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt
@@ -52,7 +52,6 @@ fun String.parseYear() = toIntOrNull()?.toDate()
fun String.parseTimestamp() = Date.from(this)
private val SEPARATOR_REGEX_CACHE = mutableMapOf()
-private val ESCAPE_REGEX_CACHE = mutableMapOf()
/**
* Fully parse a multi-value tag.
@@ -80,18 +79,10 @@ fun String.maybeParseSeparators(settings: Settings): List {
// Try to cache compiled regexes for particular separator combinations.
val regex =
synchronized(SEPARATOR_REGEX_CACHE) {
- SEPARATOR_REGEX_CACHE.getOrPut(separators) { Regex("[^\\\\][$separators]") }
+ SEPARATOR_REGEX_CACHE.getOrPut(separators) { Regex("[$separators]") }
}
- val escape =
- synchronized(ESCAPE_REGEX_CACHE) {
- ESCAPE_REGEX_CACHE.getOrPut(separators) { Regex("\\\\[$separators]") }
- }
-
- return regex.split(this).map { value ->
- // Convert escaped separators to their correct value
- escape.replace(value) { match -> match.value.substring(1) }.trim()
- }
+ return regex.split(this).map { it.trim() }
}
/** Parse a multi-value tag into a [ReleaseType], handling separators in the process. */
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt
similarity index 55%
rename from app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt
rename to app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt
index 90e3b9aac..ebe2fa62d 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt
@@ -21,29 +21,29 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding
-import org.oxycblt.auxio.music.Genre
+import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.recycler.DialogViewHolder
import org.oxycblt.auxio.ui.recycler.ItemClickListener
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
/**
- * The adapter that displays a list of genre choices in the picker UI.
+ * The adapter that displays a list of artist choices in the picker UI.
*/
-class GenreChoiceAdapter(private val listener: ItemClickListener) : RecyclerView.Adapter() {
- private var genres = listOf()
+class ArtistChoiceAdapter(private val listener: ItemClickListener) : RecyclerView.Adapter() {
+ private var artists = listOf()
- override fun getItemCount() = genres.size
+ override fun getItemCount() = artists.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- GenreChoiceViewHolder.new(parent)
+ ArtistChoiceViewHolder.new(parent)
- override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
- holder.bind(genres[position], listener)
+ override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
+ holder.bind(artists[position], listener)
- fun submitList(newGenres: List) {
- if (newGenres != genres) {
- genres = newGenres
+ fun submitList(newArtists: List) {
+ if (newArtists != artists) {
+ artists = newArtists
@Suppress("NotifyDataSetChanged")
notifyDataSetChanged()
@@ -52,20 +52,20 @@ class GenreChoiceAdapter(private val listener: ItemClickListener) : RecyclerView
}
/**
- * The ViewHolder that displays a genre choice. Smaller than other parent items due to dialog
+ * The ViewHolder that displays a artist choice. Smaller than other parent items due to dialog
* constraints.
*/
-class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : DialogViewHolder(binding.root) {
- fun bind(genre: Genre, listener: ItemClickListener) {
- binding.pickerImage.bind(genre)
- binding.pickerName.text = genre.resolveName(binding.context)
+class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : DialogViewHolder(binding.root) {
+ fun bind(artist: Artist, listener: ItemClickListener) {
+ binding.pickerImage.bind(artist)
+ binding.pickerName.text = artist.resolveName(binding.context)
binding.root.setOnClickListener {
- listener.onItemClick(genre)
+ listener.onItemClick(artist)
}
}
companion object {
fun new(parent: View) =
- GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
+ ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt
similarity index 65%
rename from app/src/main/java/org/oxycblt/auxio/music/picker/GenrePickerDialog.kt
rename to app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt
index 59aa87d64..c5997ba68 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePickerDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt
@@ -26,7 +26,9 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
-import org.oxycblt.auxio.music.Genre
+import org.oxycblt.auxio.music.Album
+import org.oxycblt.auxio.music.Artist
+import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
@@ -34,45 +36,42 @@ import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.ItemClickListener
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
-import org.oxycblt.auxio.util.unlikelyToBeNull
/**
- * A dialog that shows several genre options if the result of an genre-reliant operation is
+ * A dialog that shows several artist options if the result of an artist-reliant operation is
* ambiguous.
* @author OxygenCobalt
+ *
+ * TODO: Clean up the picker flow to reduce the amount of duplication I had to do.
*/
-class GenrePickerDialog : ViewBindingDialogFragment(), ItemClickListener {
+class ArtistPickerDialog : ViewBindingDialogFragment(), ItemClickListener {
private val pickerModel: PickerViewModel by viewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
- private val args: GenrePickerDialogArgs by navArgs()
- private val adapter = GenreChoiceAdapter(this)
+ private val args: ArtistPickerDialogArgs by navArgs()
+ private val adapter = ArtistChoiceAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicPickerBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
- .setTitle(
- when (args.pickerMode) {
- PickerMode.GO -> R.string.lbl_go_genre
- PickerMode.PLAY -> R.string.lbl_play_genre
- }
- )
+ .setTitle(R.string.lbl_artists)
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
- pickerModel.setSongUid(args.songUid)
+ pickerModel.setSongUid(args.uid)
binding.pickerRecycler.adapter = adapter
- collectImmediately(pickerModel.currentSong) { song ->
- if (song != null) {
- adapter.submitList(song.genres)
- } else {
- findNavController().navigateUp()
+ collectImmediately(pickerModel.currentItem) { item ->
+ when (item) {
+ is Song -> adapter.submitList(item.artists)
+ is Album -> adapter.submitList(item.artists)
+ null -> findNavController().navigateUp()
+ else -> error("Invalid datatype: ${item::class.java}")
}
}
}
@@ -82,13 +81,14 @@ class GenrePickerDialog : ViewBindingDialogFragment(),
}
override fun onItemClick(item: Item) {
- check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
+ check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
findNavController().navigateUp()
when (args.pickerMode) {
- PickerMode.GO -> navModel.exploreNavigateTo(item)
+ PickerMode.SHOW -> navModel.exploreNavigateTo(item)
PickerMode.PLAY -> {
- val song = unlikelyToBeNull(pickerModel.currentSong.value)
- playbackModel.playFromGenre(song, item)
+ val currentItem = pickerModel.currentItem.value
+ check(currentItem is Song) { "PickerMode.PLAY is only allowed with Songs" }
+ playbackModel.playFromArtist(currentItem, item)
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt
index 03cb26234..baf736214 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt
@@ -22,5 +22,5 @@ package org.oxycblt.auxio.music.picker
*/
enum class PickerMode {
PLAY,
- GO
+ SHOW
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt
index 9dd32a185..db51bd0a6 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt
@@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.picker
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
@@ -32,20 +33,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class PickerViewModel : ViewModel(), MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
- private val _currentSong = MutableStateFlow(null)
- val currentSong: StateFlow get() = _currentSong
+ private var _currentItem = MutableStateFlow(null)
+ val currentItem: StateFlow = _currentItem
fun setSongUid(uid: Music.UID) {
- if (_currentSong.value?.uid == uid) return
+ if (_currentItem.value?.uid == uid) return
val library = unlikelyToBeNull(musicStore.library)
- _currentSong.value = requireNotNull(library.find(uid)) { "Invalid song id provided" }
+ val item = requireNotNull(library.find(uid)) { "Invalid song id provided" }
+ _currentItem.value = item
}
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
- val song = _currentSong.value
- if (song != null) {
- _currentSong.value = library.sanitize(song)
+ when (val item = currentItem.value) {
+ is Song -> {
+ _currentItem.value = library.sanitize(item)
+ }
+ is Album -> {
+ _currentItem.value = library.sanitize(item)
+ }
+ null -> {}
+ else -> error("Invalid datatype: ${item::class.java}")
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt
index fcce67eae..1e0d3ba12 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt
@@ -52,7 +52,9 @@ class SeparatorsDialog : ViewBindingDialogFragment() {
override fun onBindingCreated(binding: DialogSeparatorsBinding, savedInstanceState: Bundle?) {
for (child in binding.separatorGroup.children) {
- (child as MaterialCheckBox).isChecked = false
+ if (child is MaterialCheckBox) {
+ child.isChecked = false
+ }
}
settings.separators?.forEach {
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
index acc36d14d..722c7ab37 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
@@ -30,6 +30,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
+import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
@@ -223,7 +224,7 @@ class Indexer {
val buildStart = System.currentTimeMillis()
val albums = buildAlbums(songs)
- val artists = buildArtists(albums)
+ val artists = buildArtists(songs, albums)
val genres = buildGenres(songs)
// Make sure we finalize all the items now that they are fully built.
@@ -265,7 +266,7 @@ class Indexer {
yield()
// Note: We use a set here so we can eliminate effective duplicates of
- // songs (by UID).
+ // songs (by UID) and sort to achieve consistent orderings
val songs = mutableSetOf()
val rawSongs = mutableListOf()
@@ -280,12 +281,10 @@ class Indexer {
metadataExtractor.finalize(rawSongs)
- val sorted = Sort(Sort.Mode.ByName, true).songs(songs)
-
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
// Ensure that sorting order is consistent so that grouping is also consistent.
- return sorted
+ return Sort(Sort.Mode.ByName, true).songs(songs)
}
/**
@@ -315,18 +314,26 @@ class Indexer {
}
/**
- * Group up albums into artists. This also requires a de-duplication step due to some edge cases
- * where [buildAlbums] could not detect duplicates.
+ * Group up songs AND albums into artists. This process seems weird (because it is), but
+ * the purpose is that the actual artist information of albums and songs often differs,
+ * and so they are linked in different ways.
*/
- private fun buildArtists(albums: List): List {
- val artists = mutableListOf()
- val albumsByArtist = albums.groupBy { it._rawArtist }
-
- for (entry in albumsByArtist) {
- // The first album will suffice for template metadata.
- artists.add(Artist(entry.key, entry.value))
+ private fun buildArtists(songs: List, albums: List): List {
+ val musicByArtist = mutableMapOf>()
+ for (song in songs) {
+ for (rawArtist in song._rawArtists) {
+ musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
+ }
}
+ for (album in albums) {
+ for (rawArtist in album._rawArtists) {
+ musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
+ }
+ }
+
+ val artists = musicByArtist.map { Artist(it.key, it.value) }
+
logD("Successfully built ${artists.size} artists")
return artists
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
index 14697035e..3c19f4d85 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
@@ -119,7 +119,7 @@ class PlaybackBarFragment : ViewBindingFragment() {
val binding = requireBinding()
binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context)
- binding.playbackInfo.text = song.resolveIndividualArtistName(context)
+ binding.playbackInfo.text = song.resolveArtistContents(context)
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
index f749a9e72..80cf949b2 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
@@ -33,6 +33,7 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.msToDs
+import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.StyledSeekBar
import org.oxycblt.auxio.ui.MainNavigationAction
@@ -87,11 +88,11 @@ class PlaybackPanelFragment :
}
binding.playbackArtist.setOnClickListener {
- playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) }
+ playbackModel.song.value?.let { showCurrentArtist() }
}
binding.playbackAlbum.setOnClickListener {
- playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) }
+ playbackModel.song.value?.let { showCurrentAlbum() }
}
binding.playbackSeekBar.callback = this
@@ -138,11 +139,11 @@ class PlaybackPanelFragment :
true
}
R.id.action_go_artist -> {
- playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) }
+ showCurrentArtist()
true
}
R.id.action_go_album -> {
- playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) }
+ showCurrentAlbum()
true
}
R.id.action_song_detail -> {
@@ -166,12 +167,11 @@ class PlaybackPanelFragment :
private fun updateSong(song: Song?) {
if (song == null) return
-
val binding = requireBinding()
val context = requireContext()
binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context)
- binding.playbackArtist.text = song.resolveIndividualArtistName(context)
+ binding.playbackArtist.text = song.resolveArtistContents(context)
binding.playbackAlbum.text = song.album.resolveName(context)
binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
}
@@ -179,7 +179,6 @@ class PlaybackPanelFragment :
private fun updateParent(parent: MusicParent?) {
val binding = requireBinding()
val context = requireContext()
-
binding.playbackToolbar.subtitle =
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
}
@@ -202,4 +201,21 @@ class PlaybackPanelFragment :
private fun updateShuffled(isShuffled: Boolean) {
requireBinding().playbackShuffle.isActivated = isShuffled
}
+
+ private fun showCurrentArtist() {
+ val song = playbackModel.song.value ?: return
+ if (song.artists.size == 1) {
+ navModel.exploreNavigateTo(song.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(song.uid, PickerMode.SHOW)
+ )
+ )
+ }
+ }
+ private fun showCurrentAlbum() {
+ val song = playbackModel.song.value ?: return
+ navModel.exploreNavigateTo(song.album)
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
index 2d18bb8f8..7d1053fe6 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
@@ -106,12 +106,14 @@ class PlaybackViewModel(application: Application) :
}
/** Play a song from it's artist. */
- fun playFromArtist(song: Song) {
- playbackManager.play(song, song.album.artist, settings, false)
+ fun playFromArtist(song: Song, artist: Artist) {
+ check(artist.songs.contains(song)) { "Invalid input: Artist is not linked to song" }
+ playbackManager.play(song, artist, settings, false)
}
/** Play a song from the specific genre that contains the song. */
fun playFromGenre(song: Song, genre: Genre) {
+ check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
playbackManager.play(song, genre, settings, false)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
index 6fff33787..24ab49087 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
@@ -140,7 +140,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
fun bind(item: Song, listener: QueueItemListener) {
binding.songAlbumCover.bind(item)
binding.songName.text = item.resolveName(binding.context)
- binding.songInfo.text = item.resolveIndividualArtistName(binding.context)
+ binding.songInfo.text = item.resolveArtistContents(binding.context)
binding.background.isInvisible = true
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
index c0f557c91..09fa5884f 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
@@ -131,28 +131,30 @@ class MediaSessionComponent(private val context: Context, private val callback:
// Note: We would leave the artist field null if it didn't exist and let downstream
// consumers handle it, but that would break the notification display.
val title = song.resolveName(context)
- val artist = song.resolveIndividualArtistName(context)
+ val artist = song.resolveArtistContents(context)
val builder =
MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
- .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
- song.album.artist.resolveName(context)
+ song.album.resolveArtistContents(context)
)
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
- .putText(
- MediaMetadataCompat.METADATA_KEY_GENRE,
- song.genres.joinToString { it.resolveName(context) }
- )
.putText(
METADATA_KEY_PARENT,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
)
+ .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.resolveGenreContents(context))
+ .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
+ .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
+ .putText(
+ MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
+ parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
+ )
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
song.track?.let {
@@ -202,7 +204,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
MediaDescriptionCompat.Builder()
.setMediaId(song.uid.toString())
.setTitle(song.resolveName(context))
- .setSubtitle(song.resolveIndividualArtistName(context))
+ .setSubtitle(song.resolveArtistContents(context))
.setIconUri(song.album.coverUri)
.setMediaUri(song.uri)
.build()
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
index 4ffd4d134..86157ee5d 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
@@ -73,9 +73,10 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
// Starting in API 24, the subtext field changed semantics from being below the
- // content text to being above the title.
+ // content text to being above the title. Use an appropriate field for both.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- setSubText(metadata.getText(MediaSessionComponent.METADATA_KEY_PARENT))
+ // Display description -> Parent in which playback is occurring
+ setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION))
} else {
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM))
}
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
index a205b37b5..4eb03b6a2 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
@@ -153,16 +153,18 @@ class SearchFragment :
is Song -> when (settings.libPlaybackMode) {
MusicMode.SONGS -> playbackModel.playFromAll(item)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
- MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
- MusicMode.GENRES -> if (item.genres.size > 1) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
+ MusicMode.ARTISTS -> {
+ if (item.artists.size == 1) {
+ playbackModel.playFromArtist(item, item.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
+ )
)
- )
- } else {
- playbackModel.playFromGenre(item, item.genres[0])
+ }
}
+ else -> error("Unexpected playback mode: ${settings.libPlaybackMode}")
}
is MusicParent -> navModel.exploreNavigateTo(item)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt
index 34fbc024b..a11451c55 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt
@@ -82,8 +82,8 @@ class Settings(private val context: Context, private val callback: Callback? = n
fun Int.migratePlaybackMode() =
when (this) {
- IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
- IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES
+ // Genre playback mode was retried in 3.0.0
+ IntegerTable.PLAYBACK_MODE_ALL_SONGS, IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.SONGS
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
else -> null
@@ -410,7 +410,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
Sort.fromIntCode(
inner.getInt(context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE)
)
- ?: Sort(Sort.Mode.ByYear, false)
+ ?: Sort(Sort.Mode.ByDate, false)
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode)
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt
index 539bea579..c9cc298f1 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt
@@ -64,7 +64,7 @@ class NavigationViewModel : ViewModel() {
/** Navigate to an item's detail menu, whether a song/album/artist */
fun exploreNavigateTo(item: Music) {
if (_exploreNavigationItem.value != null) {
- logD("Already navigation, not doing explore action")
+ logD("Already navigating, not doing explore action")
return
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt
index 271638417..01b4eb301 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt
@@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.picker.PickerMode
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
@@ -65,7 +66,15 @@ abstract class MenuFragment : ViewBindingFragment() {
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_go_artist -> {
- navModel.exploreNavigateTo(song.album.artist)
+ if (song.artists.size == 1) {
+ navModel.exploreNavigateTo(song.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(song.uid, PickerMode.SHOW)
+ )
+ )
+ }
}
R.id.action_go_album -> {
navModel.exploreNavigateTo(song.album)
@@ -110,7 +119,15 @@ abstract class MenuFragment : ViewBindingFragment() {
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_go_artist -> {
- navModel.exploreNavigateTo(album.artist)
+ if (album.artists.size == 1) {
+ navModel.exploreNavigateTo(album.artists[0])
+ } else {
+ navModel.mainNavigateTo(
+ MainNavigationAction.Directions(
+ MainFragmentDirections.actionPickArtist(album.uid, PickerMode.SHOW)
+ )
+ )
+ }
}
else -> {
error("Unexpected menu item selected")
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt
index 3cd73b9a8..cc7c5c98e 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt
@@ -41,7 +41,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bind(item)
binding.songName.text = item.resolveName(binding.context)
- binding.songInfo.text = item.resolveIndividualArtistName(binding.context)
+ binding.songInfo.text = item.resolveArtistContents(binding.context)
// binding.songMenu.setOnClickListener { listener.onOpenMenu(item, it) }
binding.root.setOnLongClickListener {
listener.onOpenMenu(item, it)
@@ -79,7 +79,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bind(item)
binding.parentName.text = item.resolveName(binding.context)
- binding.parentInfo.text = item.artist.resolveName(binding.context)
+ binding.parentInfo.text = item.resolveArtistContents(binding.context)
// binding.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) }
binding.root.setOnLongClickListener {
listener.onOpenMenu(item, it)
@@ -102,7 +102,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
object : SimpleItemCallback() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
- oldItem.artist.rawName == newItem.artist.rawName &&
+ oldItem.areArtistContentsTheSame(newItem) &&
oldItem.releaseType == newItem.releaseType
}
}
@@ -118,12 +118,18 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
fun bind(item: Artist, listener: MenuItemListener) {
binding.parentImage.bind(item)
binding.parentName.text = item.resolveName(binding.context)
- binding.parentInfo.text =
+
+ binding.parentInfo.text = if (item.songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size),
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)
)
+ } else {
+ // Artist has no songs, only display an album count.
+ binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size)
+ }
+
// binding.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) }
binding.root.setOnLongClickListener {
listener.onOpenMenu(item, it)
diff --git a/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt
index f4fba0968..74bdc0066 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt
@@ -44,6 +44,9 @@ fun unlikelyToBeNull(value: T?) =
/** Returns null if this value is 0. */
fun Int.nonZeroOrNull() = if (this > 0) this else null
+/** Returns null if this value is 0. */
+fun Long.nonZeroOrNull() = if (this > 0) this else null
+
/** Returns null if this value is not in [range]. */
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
index 715ad78ba..dda2ee12d 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
@@ -113,7 +113,7 @@ private fun RemoteViews.applyMeta(
applyCover(context, state)
setTextViewText(R.id.widget_song, state.song.resolveName(context))
- setTextViewText(R.id.widget_artist, state.song.resolveIndividualArtistName(context))
+ setTextViewText(R.id.widget_artist, state.song.resolveArtistContents(context))
return this
}
diff --git a/app/src/main/res/layout/dialog_separators.xml b/app/src/main/res/layout/dialog_separators.xml
index 05262399d..17740d56a 100644
--- a/app/src/main/res/layout/dialog_separators.xml
+++ b/app/src/main/res/layout/dialog_separators.xml
@@ -72,7 +72,14 @@
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
tools:ignore="RtlSymmetry" />
-
+
diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml
index a7132ca06..e1e7c9f91 100644
--- a/app/src/main/res/navigation/nav_main.xml
+++ b/app/src/main/res/navigation/nav_main.xml
@@ -18,17 +18,18 @@
android:id="@+id/action_show_details"
app:destination="@id/song_detail_dialog" />
+ android:id="@+id/action_pick_artist"
+ app:destination="@id/artist_picker_dialog" />
+
- @string/set_playback_mode_artist
- @string/set_playback_mode_album
- - @string/set_playback_mode_genre
- - @integer/play_mode_songs
- - @integer/play_mode_artist
- - @integer/play_mode_album
- - @integer/play_mode_genre
+ - @integer/music_mode_songs
+ - @integer/music_mode_artist
+ - @integer/music_mode_album
@@ -95,15 +93,13 @@
- @string/set_playback_mode_all
- @string/set_playback_mode_artist
- @string/set_playback_mode_album
- - @string/set_playback_mode_genre
- - @integer/play_mode_none
- - @integer/play_mode_songs
- - @integer/play_mode_artist
- - @integer/play_mode_album
- - @integer/play_mode_genre
+ - @integer/music_mode_none
+ - @integer/music_mode_songs
+ - @integer/music_mode_artist
+ - @integer/music_mode_album
@@ -126,11 +122,10 @@
0xA11A
0xA11B
- -2147483648
- 0xA108
- 0xA109
- 0xA10A
- 0xA10B
+ -2147483648
+ 0xA109
+ 0xA10A
+ 0xA10B
0xA111
0xA112
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b9aa4809a..8f9c2e009 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -105,8 +105,6 @@
Go to genre
Go to artist
Go to album
- Play from genre
- Play from artist
View properties
Song properties
@@ -182,7 +180,7 @@
Skip to next
Repeat mode
- @string/lbl_shuffle
+ @string/lbl_shuffle
Use alternate notification action
Prefer repeat mode action
Prefer shuffle action
@@ -206,8 +204,7 @@
Play from shown item
Play from all songs
Play from album
- @string/lbl_play_artist
- @string/lbl_play_genre
+ Play from artist
Remember shuffle
Keep shuffle on when playing a new song
Rewind before skipping back
@@ -237,7 +234,8 @@
Include
Music will only be loaded from the folders you add.
Multi-value separators
- Configure the characters that denote multiple values in tags
+ Configure characters that denote multiple tag values
+ Warning: Using this setting may result in some tags being incorrectly interpreted as having multiple values.
Comma (,)
Semicolon (;)
Slash (/)
diff --git a/app/src/main/res/xml/prefs_main.xml b/app/src/main/res/xml/prefs_main.xml
index d6b9e7048..44372d190 100644
--- a/app/src/main/res/xml/prefs_main.xml
+++ b/app/src/main/res/xml/prefs_main.xml
@@ -91,7 +91,7 @@