music: add multi-artist support

Add semi-complete support for multiple artists.

This changeset completely reworks the music linker to add the following
new behaviors:
1. Artists are now derived from both artist and album artist tags,
with them being linked to songs and albums respectively
2. Albums and songs can now have multiple artists that can be distinct
from eachother
3. Previous Genre picking infrastructure has been removed and replaced
with artist picking infrastructure. "Play from genre" has been retired
entirely.

This is a clean break to the previous artist model and may not work
with all libraries. Steps to migrate the music library will be added
to the changelog.

Resolves #195.
This commit is contained in:
Alexander Capehart 2022-09-23 10:09:38 -06:00
parent b6d1cd7cb0
commit 62ee46cfe6
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
43 changed files with 845 additions and 592 deletions

View file

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

View file

@ -120,7 +120,7 @@ class AlbumDetailFragment :
true
}
R.id.action_go_artist -> {
navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artist)
onNavigateToArtist()
true
}
else -> false
@ -132,17 +132,19 @@ 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) {
MusicMode.ARTISTS -> {
if (item.artists.size == 1) {
playbackModel.playFromArtist(item, item.artists[0])
} else {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
)
)
} else {
playbackModel.playFromGenre(item, item.genres[0])
}
}
else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
}
}
override fun onOpenMenu(item: Item, anchor: View) {
@ -177,13 +179,17 @@ 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?) {
if (album == null) {

View file

@ -122,19 +122,22 @@ 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) {
MusicMode.ARTISTS -> {
if (item.artists.size == 1) {
playbackModel.playFromArtist(item, item.artists[0])
} else {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
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)
else -> error("Unexpected datatype: ${item::class.simpleName}")

View file

@ -240,7 +240,7 @@ class DetailViewModel(application: Application) :
private fun refreshArtistData(artist: Artist) {
logD("Refreshing artist data")
val data = mutableListOf<Item>(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)
}
// 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()
}

View file

@ -125,17 +125,19 @@ 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) {
MusicMode.ARTISTS -> {
if (item.artists.size == 1) {
playbackModel.playFromArtist(item, item.artists[0])
} else {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
)
)
} else {
playbackModel.playFromGenre(item, item.genres[0])
}
}
else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}")
}
}
override fun onOpenMenu(item: Item, anchor: View) {

View file

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

View file

@ -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,18 +109,11 @@ 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<Genre, Int>()
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.detailSubhead.text =
genresByAmount.maxByOrNull { it.value }?.key?.resolveName(binding.context)
?: binding.context.getString(R.string.def_genre)
binding.detailInfo.text =
binding.context.getString(
@ -130,6 +122,17 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
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.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<Artist>() {
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
}
}
}

View file

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

View file

@ -66,11 +66,11 @@ class AlbumListFragment : HomeListFragment<Album>() {
// 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)

View file

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

View file

@ -79,14 +79,14 @@ class SongListFragment : HomeListFragment<Song>() {
// 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,17 +115,19 @@ class SongListFragment : HomeListFragment<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) {
MusicMode.ARTISTS -> {
if (item.artists.size == 1) {
playbackModel.playFromArtist(item, item.artists[0])
} else {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY)
)
)
} else {
playbackModel.playFromGenre(item, item.genres[0])
}
}
else -> error("Unexpected playback mode: ${settings.libPlaybackMode}")
}
}
override fun onOpenMenu(item: Item, anchor: View) {

View file

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

View file

@ -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<Song>
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<Artist> = 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 }
private val rawArtists = artistNames.mapIndexed { i, name ->
Artist.Raw(name, artistSortNames.getOrNull(i))
}
private val rawAlbumArtists = albumArtistNames.mapIndexed { i, name ->
Artist.Raw(name, albumArtistSortNames.getOrNull(i))
}
private val _artists = mutableListOf<Artist>()
/**
* 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)
* 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.
*/
fun resolveIndividualArtistName(context: Context) =
artistName ?: album.artist.resolveName(context)
val artists: List<Artist>
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 {
if (other.artistName != null && artistName != null) {
return other.artistName == artistName
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 album.artist.rawName == other.album.artist.rawName
return true
}
private val _genres: MutableList<Genre> = mutableListOf()
private val _genres = mutableListOf<Genre>()
/**
* 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<Genre>
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<Song>) : 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<Song>) : 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<Artist>()
val artists: List<Artist> 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 ---
val _rawArtist = raw.rawArtist
fun _link(artist: Artist) {
_artist = artist
/**
* 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
}
}
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<Song>) : 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<Artist.Raw>
) {
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<Album>
) : MusicParent() {
constructor(raw: Raw, songAlbums: List<Music>) : 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<Song>()
override val songs = _songs
/**
* The songs of this artist. This might be empty.
*/
override val songs: List<Song>
/** 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<Album>
for (album in albums) {
album._link(this)
_songs.addAll(album.songs)
totalDuration += album.durationMs
private lateinit var genres: List<Genre>
/**
* 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<Song>()
val distinctAlbums = mutableSetOf<Album>()
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<Song>) : MusicParent(
/** The total duration of the songs in this genre, in millis. */
val durationMs: Long
/** The albums of this genre. */
val albums: List<Album>
init {
var totalDuration = 0L
val distinctAlbums = mutableSetOf<Album>()
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<String>) {
fun MessageDigest.update(strings: List<String?>) {
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<Int>) : Comparable<Date> {
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<Int>): Date? {
val out = mutableListOf<Int>()
validateTokens(tokens, out)
if (out.isEmpty()) {
return null
}
return Date(out)
}
private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
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<String>): 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<String>.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)
}
}
}

View file

@ -102,7 +102,7 @@ class MusicStore private constructor() {
* not [T], null will be returned.
*/
@Suppress("UNCHECKED_CAST")
fun <T : Music> find(uid: Music.UID): T? = uidMap[uid] as? T
fun <T : Music> 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>(song.uid)

View file

@ -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<Artist>) {
fun artistsInPlace(artists: MutableList<Artist>) {
artists.sortWith(mode.getArtistComparator(isAscending))
}
private fun genresInPlace(genres: MutableList<Genre>) {
fun genresInPlace(genres: MutableList<Genre>) {
genres.sortWith(mode.getGenreComparator(isAscending))
}
@ -154,7 +155,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
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<Album> =
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<Artist> =
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<Artist> =
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<T>(private val inner: Comparator<T>) : Comparator<List<T>> {
override fun compare(a: List<T>, b: List<T>): 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<List<Artist>> = ListComparator(BasicComparator.ARTIST)
}
}
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
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<T : Comparable<T>> private constructor() : Comparator<T?> {
private class NullableComparator<T : Comparable<T>> private constructor() : Comparator<T?> {
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<Int>()
val LONG = NullableComparator<Long>()
val DATE = NullableComparator<Date>()
}
}
@ -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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int>) : Comparable<Date> {
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<Int>): Date? {
val out = mutableListOf<Int>()
validateTokens(tokens, out)
if (out.isEmpty()) {
return null
}
return Date(out)
}
private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
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<String>): 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<String>.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)
}
}
}

View file

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

View file

@ -52,7 +52,6 @@ fun String.parseYear() = toIntOrNull()?.toDate()
fun String.parseTimestamp() = Date.from(this)
private val SEPARATOR_REGEX_CACHE = mutableMapOf<String, Regex>()
private val ESCAPE_REGEX_CACHE = mutableMapOf<String, Regex>()
/**
* Fully parse a multi-value tag.
@ -80,18 +79,10 @@ fun String.maybeParseSeparators(settings: Settings): List<String> {
// 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. */

View file

@ -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<GenreChoiceViewHolder>() {
private var genres = listOf<Genre>()
class ArtistChoiceAdapter(private val listener: ItemClickListener) : RecyclerView.Adapter<ArtistChoiceViewHolder>() {
private var artists = listOf<Artist>()
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<Genre>) {
if (newGenres != genres) {
genres = newGenres
fun submitList(newArtists: List<Artist>) {
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))
}
}

View file

@ -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<DialogMusicPickerBinding>(), ItemClickListener {
class ArtistPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), 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<DialogMusicPickerBinding>(),
}
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)
}
}
}

View file

@ -22,5 +22,5 @@ package org.oxycblt.auxio.music.picker
*/
enum class PickerMode {
PLAY,
GO
SHOW
}

View file

@ -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<Song?>(null)
val currentSong: StateFlow<Song?> get() = _currentSong
private var _currentItem = MutableStateFlow<Music?>(null)
val currentItem: StateFlow<Music?> = _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}")
}
}
}

View file

@ -52,7 +52,9 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
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 {

View file

@ -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<Song>()
val rawSongs = mutableListOf<Song.Raw>()
@ -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,17 +314,25 @@ 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<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
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<Song>, albums: List<Album>): List<Artist> {
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
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")

View file

@ -119,7 +119,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
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()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -153,17 +153,19 @@ 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) {
MusicMode.ARTISTS -> {
if (item.artists.size == 1) {
playbackModel.playFromArtist(item, item.artists[0])
} else {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY)
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)
}
}

View file

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

View file

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

View file

@ -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<T : ViewBinding> : ViewBindingFragment<T>() {
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<T : ViewBinding> : ViewBindingFragment<T>() {
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")

View file

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

View file

@ -44,6 +44,9 @@ fun <T> 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

View file

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

View file

@ -72,7 +72,14 @@
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
tools:ignore="RtlSymmetry" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Auxio.BodySmall"
android:layout_marginTop="@dimen/spacing_mid_large"
android:layout_marginStart="@dimen/spacing_mid_large"
android:layout_marginEnd="@dimen/spacing_mid_large"
android:text="@string/set_separators_warning"/>
</LinearLayout>

View file

@ -18,17 +18,18 @@
android:id="@+id/action_show_details"
app:destination="@id/song_detail_dialog" />
<action
android:id="@+id/show_genre_picker_dialog"
app:destination="@id/genre_picker_dialog" />
android:id="@+id/action_pick_artist"
app:destination="@id/artist_picker_dialog" />
</fragment>
<dialog
android:id="@+id/genre_picker_dialog"
android:name="org.oxycblt.auxio.music.picker.GenrePickerDialog"
android:label="genre_picker_dialog"
android:id="@+id/artist_picker_dialog"
android:name="org.oxycblt.auxio.music.picker.ArtistPickerDialog"
android:label="artist_picker_dialog"
tools:layout="@layout/dialog_music_picker">
<argument
android:name="songUid"
android:name="uid"
app:argType="org.oxycblt.auxio.music.Music$UID" />
<argument
android:name="pickerMode"

View file

@ -80,14 +80,12 @@
<item>@string/set_playback_mode_all</item>
<item>@string/set_playback_mode_artist</item>
<item>@string/set_playback_mode_album</item>
<item>@string/set_playback_mode_genre</item>
</string-array>
<integer-array name="values_library_song_playback_mode">
<item>@integer/play_mode_songs</item>
<item>@integer/play_mode_artist</item>
<item>@integer/play_mode_album</item>
<item>@integer/play_mode_genre</item>
<item>@integer/music_mode_songs</item>
<item>@integer/music_mode_artist</item>
<item>@integer/music_mode_album</item>
</integer-array>
<string-array name="entries_detail_song_playback_mode">
@ -95,15 +93,13 @@
<item>@string/set_playback_mode_all</item>
<item>@string/set_playback_mode_artist</item>
<item>@string/set_playback_mode_album</item>
<item>@string/set_playback_mode_genre</item>
</string-array>
<integer-array name="values_detail_song_playback_mode">
<item>@integer/play_mode_none</item>
<item>@integer/play_mode_songs</item>
<item>@integer/play_mode_artist</item>
<item>@integer/play_mode_album</item>
<item>@integer/play_mode_genre</item>
<item>@integer/music_mode_none</item>
<item>@integer/music_mode_songs</item>
<item>@integer/music_mode_artist</item>
<item>@integer/music_mode_album</item>
</integer-array>
<string-array name="entries_replay_gain">
@ -126,11 +122,10 @@
<integer name="bar_action_repeat">0xA11A</integer>
<integer name="bar_action_shuffle">0xA11B</integer>
<integer name="play_mode_none">-2147483648</integer>
<integer name="play_mode_genre">0xA108</integer>
<integer name="play_mode_artist">0xA109</integer>
<integer name="play_mode_album">0xA10A</integer>
<integer name="play_mode_songs">0xA10B</integer>
<integer name="music_mode_none">-2147483648</integer>
<integer name="music_mode_artist">0xA109</integer>
<integer name="music_mode_album">0xA10A</integer>
<integer name="music_mode_songs">0xA10B</integer>
<integer name="replay_gain_track">0xA111</integer>
<integer name="replay_gain_album">0xA112</integer>

View file

@ -105,8 +105,6 @@
<string name="lbl_go_genre">Go to genre</string>
<string name="lbl_go_artist">Go to artist</string>
<string name="lbl_go_album">Go to album</string>
<string name="lbl_play_genre">Play from genre</string>
<string name="lbl_play_artist">Play from artist</string>
<string name="lbl_song_detail">View properties</string>
<string name="lbl_props">Song properties</string>
@ -182,7 +180,7 @@
<!-- Skip to next (song) -->
<string name="set_bar_action_next">Skip to next</string>
<string name="set_bar_action_repeat">Repeat mode</string>
<string name="set_bar_action_shuffle">@string/lbl_shuffle</string>
<string name="set_bar_action_shuffle" translatable="false">@string/lbl_shuffle</string>
<string name="set_alt_action">Use alternate notification action</string>
<string name="set_alt_repeat">Prefer repeat mode action</string>
<string name="set_alt_shuffle">Prefer shuffle action</string>
@ -206,8 +204,7 @@
<string name="set_playback_mode_none">Play from shown item</string>
<string name="set_playback_mode_all">Play from all songs</string>
<string name="set_playback_mode_album">Play from album</string>
<string name="set_playback_mode_artist">@string/lbl_play_artist</string>
<string name="set_playback_mode_genre">@string/lbl_play_genre</string>
<string name="set_playback_mode_artist">Play from artist</string>
<string name="set_keep_shuffle">Remember shuffle</string>
<string name="set_keep_shuffle_desc">Keep shuffle on when playing a new song</string>
<string name="set_rewind_prev">Rewind before skipping back</string>
@ -237,7 +234,8 @@
<string name="set_dirs_mode_include">Include</string>
<string name="set_dirs_mode_include_desc">Music will <b>only</b> be loaded from the folders you add.</string>
<string name="set_separators">Multi-value separators</string>
<string name="set_separators_desc">Configure the characters that denote multiple values in tags</string>
<string name="set_separators_desc">Configure characters that denote multiple tag values</string>
<string name="set_separators_warning">Warning: Using this setting may result in some tags being incorrectly interpreted as having multiple values.</string>
<string name="set_separators_comma">Comma (,)</string>
<string name="set_separators_semicolon">Semicolon (;)</string>
<string name="set_separators_slash">Slash (/)</string>

View file

@ -91,7 +91,7 @@
<PreferenceCategory app:title="@string/set_behavior">
<org.oxycblt.auxio.settings.IntListPreference
app:defaultValue="@integer/play_mode_songs"
app:defaultValue="@integer/music_mode_songs"
app:entries="@array/entries_library_song_playback_mode"
app:entryValues="@array/values_library_song_playback_mode"
app:key="@string/set_key_library_song_playback_mode"
@ -99,7 +99,7 @@
app:useSimpleSummaryProvider="true" />
<org.oxycblt.auxio.settings.IntListPreference
app:defaultValue="@integer/play_mode_none"
app:defaultValue="@integer/music_mode_none"
app:entries="@array/entries_detail_song_playback_mode"
app:entryValues="@array/values_detail_song_playback_mode"
app:key="@string/set_key_detail_song_playback_mode"

View file

@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.0-alpha10'
classpath 'com.android.tools.build:gradle:7.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.10.0"