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:
parent
b6d1cd7cb0
commit
62ee46cfe6
43 changed files with 845 additions and 592 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -1,10 +1,15 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## dev
|
## 3.0.0
|
||||||
|
|
||||||
#### What's New
|
#### What's New
|
||||||
- Added support for songs with multiple genres
|
- Massively reworked music loading system:
|
||||||
- Reworked music hashing to be even more reliable (Will wipe playback state)
|
- 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
|
#### What's Improved
|
||||||
- Sorting now takes accented characters into account
|
- Sorting now takes accented characters into account
|
||||||
|
@ -17,9 +22,11 @@
|
||||||
- Fixed issue where the playback progress would continue in the notification even if
|
- Fixed issue where the playback progress would continue in the notification even if
|
||||||
audio focus was lost
|
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 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
|
#### What's Changed
|
||||||
- Ignore MediaStore tags is now on by default
|
- Ignore MediaStore tags is now on by default
|
||||||
|
- Removed the "Play from genre" option in the library/detail playback mode settings
|
||||||
|
|
||||||
#### Dev/Meta
|
#### Dev/Meta
|
||||||
- Completed migration to reactive playback system
|
- Completed migration to reactive playback system
|
||||||
|
|
|
@ -120,7 +120,7 @@ class AlbumDetailFragment :
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_go_artist -> {
|
R.id.action_go_artist -> {
|
||||||
navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artist)
|
onNavigateToArtist()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
|
@ -132,17 +132,19 @@ class AlbumDetailFragment :
|
||||||
when (settings.detailPlaybackMode) {
|
when (settings.detailPlaybackMode) {
|
||||||
null, MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
null, MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
||||||
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
||||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
|
MusicMode.ARTISTS -> {
|
||||||
MusicMode.GENRES -> if (item.genres.size > 1) {
|
if (item.artists.size == 1) {
|
||||||
|
playbackModel.playFromArtist(item, item.artists[0])
|
||||||
|
} else {
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(
|
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) {
|
override fun onOpenMenu(item: Item, anchor: View) {
|
||||||
|
@ -177,13 +179,17 @@ class AlbumDetailFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNavigateToArtist() {
|
override fun onNavigateToArtist() {
|
||||||
findNavController()
|
val album = unlikelyToBeNull(detailModel.currentAlbum.value)
|
||||||
.navigate(
|
if (album.artists.size == 1) {
|
||||||
AlbumDetailFragmentDirections.actionShowArtist(
|
navModel.exploreNavigateTo(album.artists[0])
|
||||||
unlikelyToBeNull(detailModel.currentAlbum.value).artist.uid
|
} else {
|
||||||
|
navModel.mainNavigateTo(
|
||||||
|
MainNavigationAction.Directions(
|
||||||
|
MainFragmentDirections.actionPickArtist(album.uid, PickerMode.SHOW)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleItemChange(album: Album?) {
|
private fun handleItemChange(album: Album?) {
|
||||||
if (album == null) {
|
if (album == null) {
|
||||||
|
|
|
@ -122,19 +122,22 @@ class ArtistDetailFragment :
|
||||||
when (item) {
|
when (item) {
|
||||||
is Song -> {
|
is Song -> {
|
||||||
when (settings.detailPlaybackMode) {
|
when (settings.detailPlaybackMode) {
|
||||||
null, MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
|
null -> playbackModel.playFromArtist(item, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
||||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(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(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(
|
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)
|
is Album -> navModel.exploreNavigateTo(item)
|
||||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||||
|
|
|
@ -240,7 +240,7 @@ class DetailViewModel(application: Application) :
|
||||||
private fun refreshArtistData(artist: Artist) {
|
private fun refreshArtistData(artist: Artist) {
|
||||||
logD("Refreshing artist data")
|
logD("Refreshing artist data")
|
||||||
val data = mutableListOf<Item>(artist)
|
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 =
|
val byReleaseGroup =
|
||||||
albums.groupBy {
|
albums.groupBy {
|
||||||
|
@ -265,8 +265,12 @@ class DetailViewModel(application: Application) :
|
||||||
data.addAll(entry.value)
|
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.add(SortHeader(R.string.lbl_songs))
|
||||||
data.addAll(artistSort.songs(artist.songs))
|
data.addAll(artistSort.songs(artist.songs))
|
||||||
|
}
|
||||||
|
|
||||||
_artistData.value = data.toList()
|
_artistData.value = data.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -125,17 +125,19 @@ class GenreDetailFragment :
|
||||||
null -> playbackModel.playFromGenre(item, unlikelyToBeNull(detailModel.currentGenre.value))
|
null -> playbackModel.playFromGenre(item, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
||||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
||||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
|
MusicMode.ARTISTS -> {
|
||||||
MusicMode.GENRES -> if (item.genres.size > 1) {
|
if (item.artists.size == 1) {
|
||||||
|
playbackModel.playFromArtist(item, item.artists[0])
|
||||||
|
} else {
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(
|
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) {
|
override fun onOpenMenu(item: Item, anchor: View) {
|
||||||
|
|
|
@ -114,7 +114,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
||||||
binding.detailName.text = item.resolveName(binding.context)
|
binding.detailName.text = item.resolveName(binding.context)
|
||||||
|
|
||||||
binding.detailSubhead.apply {
|
binding.detailSubhead.apply {
|
||||||
text = item.artist.resolveName(context)
|
text = item.resolveArtistContents(context)
|
||||||
setOnClickListener { listener.onNavigateToArtist() }
|
setOnClickListener { listener.onNavigateToArtist() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,7 +144,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
||||||
object : SimpleItemCallback<Album>() {
|
object : SimpleItemCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.artist.rawName == newItem.artist.rawName &&
|
oldItem.areArtistContentsTheSame(newItem) &&
|
||||||
oldItem.date == newItem.date &&
|
oldItem.date == newItem.date &&
|
||||||
oldItem.songs.size == newItem.songs.size &&
|
oldItem.songs.size == newItem.songs.size &&
|
||||||
oldItem.durationMs == newItem.durationMs &&
|
oldItem.durationMs == newItem.durationMs &&
|
||||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
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.databinding.ItemSongBinding
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.resolveYear
|
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.IndicatorAdapter
|
||||||
import org.oxycblt.auxio.ui.recycler.Item
|
import org.oxycblt.auxio.ui.recycler.Item
|
||||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
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.detailType.text = binding.context.getString(R.string.lbl_artist)
|
||||||
binding.detailName.text = item.resolveName(binding.context)
|
binding.detailName.text = item.resolveName(binding.context)
|
||||||
|
|
||||||
// Get the genre that corresponds to the most songs in this artist, which would be
|
if (item.songs.isNotEmpty()) {
|
||||||
// the most "Prominent" genre.
|
binding.detailSubhead.apply {
|
||||||
val genresByAmount = mutableMapOf<Genre, Int>()
|
isVisible = true
|
||||||
for (song in item.songs) {
|
text = item.resolveGenreContents(binding.context)
|
||||||
for (genre in song.genres) {
|
|
||||||
genresByAmount[genre] = genresByAmount[genre]?.inc() ?: 1
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailSubhead.text =
|
|
||||||
genresByAmount.maxByOrNull { it.value }?.key?.resolveName(binding.context)
|
|
||||||
?: binding.context.getString(R.string.def_genre)
|
|
||||||
|
|
||||||
binding.detailInfo.text =
|
binding.detailInfo.text =
|
||||||
binding.context.getString(
|
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.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.detailPlayButton.setOnClickListener { listener.onPlayParent() }
|
||||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
||||||
}
|
}
|
||||||
|
@ -140,7 +143,13 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
|
||||||
fun new(parent: View) =
|
fun new(parent: View) =
|
||||||
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
@ -95,9 +96,13 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
|
||||||
binding.detailCover.bind(item)
|
binding.detailCover.bind(item)
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
|
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
|
||||||
binding.detailName.text = item.resolveName(binding.context)
|
binding.detailName.text = item.resolveName(binding.context)
|
||||||
binding.detailSubhead.text =
|
binding.detailSubhead.isVisible = false
|
||||||
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)
|
binding.detailInfo.text = binding.context.getString(
|
||||||
binding.detailInfo.text = item.durationMs.formatDurationMs(false)
|
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.detailPlayButton.setOnClickListener { listener.onPlayParent() }
|
||||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,11 +66,11 @@ class AlbumListFragment : HomeListFragment<Album>() {
|
||||||
// By Name -> Use Name
|
// By Name -> Use Name
|
||||||
is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() }
|
is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// By Artist -> Use Artist Name
|
// By Artist -> Use name of first artist
|
||||||
is Sort.Mode.ByArtist -> album.artist.collationKey?.run { sourceString.first().uppercase() }
|
is Sort.Mode.ByArtist -> album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// Year -> Use Full Year
|
||||||
is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext())
|
is Sort.Mode.ByDate -> album.date?.resolveYear(requireContext())
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
||||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.ui.recycler.Item
|
||||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [HomeListFragment] for showing a list of [Artist]s.
|
* 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() }
|
is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> artist.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||||
|
|
||||||
// Count -> Use song count
|
// 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
|
// Unsupported sort, error gracefully
|
||||||
else -> null
|
else -> null
|
||||||
|
|
|
@ -79,14 +79,14 @@ class SongListFragment : HomeListFragment<Song>() {
|
||||||
// Name -> Use name
|
// Name -> Use name
|
||||||
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() }
|
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Artist -> Use Artist Name
|
// Artist -> Use name of first artist
|
||||||
is Sort.Mode.ByArtist -> song.album.artist.collationKey?.run { sourceString.first().uppercase() }
|
is Sort.Mode.ByArtist -> song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Album -> Use Album Name
|
// Album -> Use Album Name
|
||||||
is Sort.Mode.ByAlbum -> song.album.collationKey?.run { sourceString.first().uppercase() }
|
is Sort.Mode.ByAlbum -> song.album.collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// 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
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||||
|
@ -115,17 +115,19 @@ class SongListFragment : HomeListFragment<Song>() {
|
||||||
when (settings.libPlaybackMode) {
|
when (settings.libPlaybackMode) {
|
||||||
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
||||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
||||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
|
MusicMode.ARTISTS -> {
|
||||||
MusicMode.GENRES -> if (item.genres.size > 1) {
|
if (item.artists.size == 1) {
|
||||||
|
playbackModel.playFromArtist(item, item.artists[0])
|
||||||
|
} else {
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(
|
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) {
|
override fun onOpenMenu(item: Item, anchor: View) {
|
||||||
|
|
|
@ -108,22 +108,8 @@ private constructor(
|
||||||
private val genre: Genre
|
private val genre: Genre
|
||||||
) : BaseFetcher() {
|
) : BaseFetcher() {
|
||||||
override suspend fun fetch(): FetchResult? {
|
override suspend fun fetch(): FetchResult? {
|
||||||
// Genre logic is the most complicated, as we want to ensure album cover variation (i.e
|
val results = genre.albums.mapAtMost(4) { fetchArt(context, it) }
|
||||||
// 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 = albums.mapAtMost(4) { album -> fetchArt(context, album) }
|
|
||||||
return createMosaic(context, results, size)
|
return createMosaic(context, results, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,15 +23,12 @@ import android.content.Context
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.oxycblt.auxio.BuildConfig
|
|
||||||
import org.oxycblt.auxio.R
|
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.parseId3GenreNames
|
||||||
import org.oxycblt.auxio.music.extractor.parseMultiValue
|
import org.oxycblt.auxio.music.extractor.parseMultiValue
|
||||||
import org.oxycblt.auxio.music.extractor.parseReleaseType
|
import org.oxycblt.auxio.music.extractor.parseReleaseType
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.ui.recycler.Item
|
import org.oxycblt.auxio.ui.recycler.Item
|
||||||
import org.oxycblt.auxio.util.inRangeOrNull
|
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
@ -39,7 +36,6 @@ import java.text.CollationKey
|
||||||
import java.text.Collator
|
import java.text.Collator
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
// --- MUSIC MODELS ---
|
// --- MUSIC MODELS ---
|
||||||
|
|
||||||
|
@ -200,10 +196,6 @@ sealed class Music : Item {
|
||||||
sealed class MusicParent : Music() {
|
sealed class MusicParent : Music() {
|
||||||
/** The songs that this parent owns. */
|
/** The songs that this parent owns. */
|
||||||
abstract val songs: List<Song>
|
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) {
|
override val uid = UID.hashed(MusicMode.SONGS) {
|
||||||
// Song UIDs are based on the raw data without parsing so that they remain
|
// 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
|
// 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.name)
|
||||||
update(raw.albumName)
|
update(raw.albumName)
|
||||||
update(raw.date)
|
update(raw.date)
|
||||||
|
@ -274,41 +266,63 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
||||||
|
|
||||||
private var _album: Album? = null
|
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
|
val album: Album
|
||||||
get() = unlikelyToBeNull(_album)
|
get() = unlikelyToBeNull(_album)
|
||||||
|
|
||||||
// TODO: Multi-artist support
|
private val artistNames = raw.artistNames.parseMultiValue(settings)
|
||||||
// private val _artists: MutableList<Artist> = mutableListOf()
|
|
||||||
|
|
||||||
private val artistName = raw.artistNames.parseMultiValue(settings)
|
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings)
|
||||||
.joinToString().ifEmpty { null }
|
|
||||||
|
|
||||||
private val albumArtistName = raw.albumArtistNames.parseMultiValue(settings)
|
private val artistSortNames = raw.artistSortNames.parseMultiValue(settings)
|
||||||
.joinToString().ifEmpty { null }
|
|
||||||
|
|
||||||
private val artistSortName = raw.artistSortNames.parseMultiValue(settings)
|
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings)
|
||||||
.joinToString().ifEmpty { null }
|
|
||||||
|
|
||||||
private val albumArtistSortName = raw.albumArtistSortNames.parseMultiValue(settings)
|
private val rawArtists = artistNames.mapIndexed { i, name ->
|
||||||
.joinToString().ifEmpty { null }
|
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
|
* The artists of this song. Most often one, but there could be multiple. These artists
|
||||||
* falls back to the album artist tag (i.e parent artist name)
|
* 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) =
|
val artists: List<Artist>
|
||||||
artistName ?: album.artist.resolveName(context)
|
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 {
|
fun areArtistContentsTheSame(other: Song): Boolean {
|
||||||
if (other.artistName != null && artistName != null) {
|
for (i in 0 until max(artists.size, other.artists.size)) {
|
||||||
return other.artistName == artistName
|
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
|
* 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>
|
val genres: List<Genre>
|
||||||
get() = _genres
|
get() = _genres
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the genres of the song into a human-readable string.
|
||||||
|
*/
|
||||||
|
fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
|
||||||
|
|
||||||
// --- INTERNAL FIELDS ---
|
// --- 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 =
|
val _rawAlbum =
|
||||||
Album.Raw(
|
Album.Raw(
|
||||||
mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" },
|
mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" },
|
||||||
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
|
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
|
||||||
sortName = raw.albumSortName,
|
sortName = raw.albumSortName,
|
||||||
releaseType = raw.albumReleaseType.parseReleaseType(settings),
|
releaseType = raw.albumReleaseType.parseReleaseType(settings),
|
||||||
rawArtist =
|
rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }
|
||||||
if (albumArtistName != null) {
|
|
||||||
Artist.Raw(albumArtistName, albumArtistSortName)
|
|
||||||
} else {
|
|
||||||
Artist.Raw(artistName, artistSortName)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val _rawGenres = raw.genreNames.parseId3GenreNames(settings)
|
|
||||||
.map { Genre.Raw(it) }.ifEmpty { listOf(Genre.Raw(null)) }
|
|
||||||
|
|
||||||
fun _link(album: Album) {
|
fun _link(album: Album) {
|
||||||
_album = album
|
_album = album
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun _link(artist: Artist) {
|
||||||
|
_artists.add(artist)
|
||||||
|
}
|
||||||
|
|
||||||
fun _link(genre: Genre) {
|
fun _link(genre: Genre) {
|
||||||
_genres.add(genre)
|
_genres.add(genre)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun _finalize() {
|
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" }
|
check(_genres.isNotEmpty()) { "Malformed song: No genres" }
|
||||||
|
Sort(Sort.Mode.ByName, true).genresInPlace(_genres)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Raw
|
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
|
// 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.
|
// the exact same name, but if there is, I would love to know.
|
||||||
update(raw.name)
|
update(raw.name)
|
||||||
update(raw.rawArtist.name)
|
update(raw.rawArtists.map { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
override val rawName = raw.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. */
|
/** The earliest date a song in this album was added. */
|
||||||
val dateAdded: Long
|
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
|
* Resolve the artists of this album in a human-readable manner.
|
||||||
get() = unlikelyToBeNull(_artist)
|
*/
|
||||||
|
fun resolveArtistContents(context: Context) =
|
||||||
|
artists.joinToString { it.resolveName(context) }
|
||||||
|
|
||||||
// --- INTERNAL FIELDS ---
|
/**
|
||||||
|
* Utility for RecyclerView differs to check if resolveArtistContents is the same without
|
||||||
val _rawArtist = raw.rawArtist
|
* a context.
|
||||||
|
*/
|
||||||
fun _link(artist: Artist) {
|
fun areArtistContentsTheSame(other: Album): Boolean {
|
||||||
_artist = artist
|
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() {
|
return true
|
||||||
super._finalize()
|
|
||||||
checkNotNull(_artist) { "Invalid album: Artist is null " }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -462,32 +497,43 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
|
||||||
dateAdded = earliestDateAdded
|
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(
|
class Raw(
|
||||||
val mediaStoreId: Long,
|
val mediaStoreId: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val sortName: String?,
|
val sortName: String?,
|
||||||
val releaseType: ReleaseType?,
|
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 hashCode() = hashCode
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
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
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
class Artist
|
class Artist
|
||||||
constructor(
|
constructor(raw: Raw, songAlbums: List<Music>) : MusicParent() {
|
||||||
raw: Raw,
|
|
||||||
/** The albums of this artist. */
|
|
||||||
val albums: List<Album>
|
|
||||||
) : MusicParent() {
|
|
||||||
override val uid = UID.hashed(MusicMode.ARTISTS) { update(raw.name) }
|
override val uid = UID.hashed(MusicMode.ARTISTS) { update(raw.name) }
|
||||||
|
|
||||||
override val rawName = raw.name
|
override val rawName = raw.name
|
||||||
|
@ -498,22 +544,71 @@ constructor(
|
||||||
|
|
||||||
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
|
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. */
|
/** The total duration of songs in this artist, in millis. Null if there are no songs. */
|
||||||
val durationMs: Long
|
val durationMs: Long?
|
||||||
|
|
||||||
init {
|
/** The albums of this artist. This will never be empty. */
|
||||||
var totalDuration = 0L
|
val albums: List<Album>
|
||||||
|
|
||||||
for (album in albums) {
|
private lateinit var genres: List<Genre>
|
||||||
album._link(this)
|
|
||||||
_songs.addAll(album.songs)
|
/**
|
||||||
totalDuration += album.durationMs
|
* 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?) {
|
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. */
|
/** The total duration of the songs in this genre, in millis. */
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
|
|
||||||
|
/** The albums of this genre. */
|
||||||
|
val albums: List<Album>
|
||||||
|
|
||||||
init {
|
init {
|
||||||
var totalDuration = 0L
|
var totalDuration = 0L
|
||||||
|
val distinctAlbums = mutableSetOf<Album>()
|
||||||
|
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
song._link(this)
|
song._link(this)
|
||||||
|
distinctAlbums.add(song.album)
|
||||||
totalDuration += song.durationMs
|
totalDuration += song.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
durationMs = totalDuration
|
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?) {
|
class Raw(val name: String?) {
|
||||||
|
@ -591,7 +700,7 @@ fun MessageDigest.update(date: Date?) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update the digest using a list of strings. */
|
/** Update the digest using a list of strings. */
|
||||||
fun MessageDigest.update(strings: List<String>) {
|
fun MessageDigest.update(strings: List<String?>) {
|
||||||
strings.forEach(::update)
|
strings.forEach(::update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -656,263 +765,3 @@ fun ByteArray.toUuid(): UUID {
|
||||||
.or(get(15).toLong().and(0xFF))
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -102,7 +102,7 @@ class MusicStore private constructor() {
|
||||||
* not [T], null will be returned.
|
* not [T], null will be returned.
|
||||||
*/
|
*/
|
||||||
@Suppress("UNCHECKED_CAST")
|
@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. */
|
/** Sanitize an old item to find the corresponding item in a new library. */
|
||||||
fun sanitize(song: Song) = find<Song>(song.uid)
|
fun sanitize(song: Song) = find<Song>(song.uid)
|
||||||
|
|
|
@ -21,13 +21,14 @@ import androidx.annotation.IdRes
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Sort.Mode
|
import org.oxycblt.auxio.music.Sort.Mode
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the sort modes used in Auxio.
|
* Represents the sort modes used in Auxio.
|
||||||
*
|
*
|
||||||
* Sorting can be done by Name, Artist, Album, and others. Sorting of names is always
|
* 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
|
* 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]).
|
* [Mode.ByAlbum]).
|
||||||
*
|
*
|
||||||
* Internally, sorts are saved as an integer in the following format
|
* 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))
|
albums.sortWith(mode.getAlbumComparator(isAscending))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artistsInPlace(artists: MutableList<Artist>) {
|
fun artistsInPlace(artists: MutableList<Artist>) {
|
||||||
artists.sortWith(mode.getArtistComparator(isAscending))
|
artists.sortWith(mode.getArtistComparator(isAscending))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun genresInPlace(genres: MutableList<Genre>) {
|
fun genresInPlace(genres: MutableList<Genre>) {
|
||||||
genres.sortWith(mode.getGenreComparator(isAscending))
|
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> =
|
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(ascending, BasicComparator.ARTIST) { it.album.artist },
|
compareByDynamic(ascending, ListComparator.ARTISTS) { it.artists },
|
||||||
compareByDescending(NullableComparator.DATE) { it.album.date },
|
compareByDescending(NullableComparator.DATE) { it.album.date },
|
||||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||||
compareBy(NullableComparator.INT) { it.disc },
|
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> =
|
override fun getAlbumComparator(ascending: Boolean): Comparator<Album> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(ascending, BasicComparator.ARTIST) { it.artist },
|
compareByDynamic(ascending, ListComparator.ARTISTS) { it.artists },
|
||||||
compareByDescending(NullableComparator.DATE) { it.date },
|
compareByDescending(NullableComparator.DATE) { it.date },
|
||||||
compareBy(BasicComparator.ALBUM)
|
compareBy(BasicComparator.ALBUM)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sort by the year of an item, only supported by [Album] and [Song] */
|
/** Sort by the date of an item, only supported by [Album] and [Song] */
|
||||||
object ByYear : Mode() {
|
object ByDate : Mode() {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_YEAR
|
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> =
|
override fun getArtistComparator(ascending: Boolean): Comparator<Artist> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(ascending) { it.durationMs },
|
compareByDynamic(ascending, NullableComparator.LONG) { it.durationMs },
|
||||||
compareBy(BasicComparator.ARTIST)
|
compareBy(BasicComparator.ARTIST)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -243,7 +244,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
|
|
||||||
override fun getArtistComparator(ascending: Boolean): Comparator<Artist> =
|
override fun getArtistComparator(ascending: Boolean): Comparator<Artist> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(ascending) { it.songs.size },
|
compareByDynamic(ascending, NullableComparator.INT) { it.songs.size },
|
||||||
compareBy(BasicComparator.ARTIST)
|
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> {
|
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
|
||||||
override fun compare(a: T, b: T): Int {
|
override fun compare(a: T, b: T): Int {
|
||||||
val aKey = a.collationKey
|
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?) =
|
override fun compare(a: T?, b: T?) =
|
||||||
when {
|
when {
|
||||||
a != null && b != null -> a.compareTo(b)
|
a != null && b != null -> a.compareTo(b)
|
||||||
|
@ -393,6 +420,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val INT = NullableComparator<Int>()
|
val INT = NullableComparator<Int>()
|
||||||
|
val LONG = NullableComparator<Long>()
|
||||||
val DATE = NullableComparator<Date>()
|
val DATE = NullableComparator<Date>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -403,7 +431,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
ByName.itemId -> ByName
|
ByName.itemId -> ByName
|
||||||
ByAlbum.itemId -> ByAlbum
|
ByAlbum.itemId -> ByAlbum
|
||||||
ByArtist.itemId -> ByArtist
|
ByArtist.itemId -> ByArtist
|
||||||
ByYear.itemId -> ByYear
|
ByDate.itemId -> ByDate
|
||||||
ByDuration.itemId -> ByDuration
|
ByDuration.itemId -> ByDuration
|
||||||
ByCount.itemId -> ByCount
|
ByCount.itemId -> ByCount
|
||||||
ByDisc.itemId -> ByDisc
|
ByDisc.itemId -> ByDisc
|
||||||
|
@ -428,7 +456,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
Mode.ByName.intCode -> Mode.ByName
|
Mode.ByName.intCode -> Mode.ByName
|
||||||
Mode.ByArtist.intCode -> Mode.ByArtist
|
Mode.ByArtist.intCode -> Mode.ByArtist
|
||||||
Mode.ByAlbum.intCode -> Mode.ByAlbum
|
Mode.ByAlbum.intCode -> Mode.ByAlbum
|
||||||
Mode.ByYear.intCode -> Mode.ByYear
|
Mode.ByDate.intCode -> Mode.ByDate
|
||||||
Mode.ByDuration.intCode -> Mode.ByDuration
|
Mode.ByDuration.intCode -> Mode.ByDuration
|
||||||
Mode.ByCount.intCode -> Mode.ByCount
|
Mode.ByCount.intCode -> Mode.ByCount
|
||||||
Mode.ByDisc.intCode -> Mode.ByDisc
|
Mode.ByDisc.intCode -> Mode.ByDisc
|
||||||
|
|
293
app/src/main/java/org/oxycblt/auxio/music/Tags.kt
Normal file
293
app/src/main/java/org/oxycblt/auxio/music/Tags.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -224,7 +224,7 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
tags["TSOA"]?.let { raw.albumSortName = it[0] }
|
tags["TSOA"]?.let { raw.albumSortName = it[0] }
|
||||||
|
|
||||||
// (Sort) Artist
|
// (Sort) Artist
|
||||||
tags["TPE1"]?.let { raw.artistNames = it }
|
(tags["TXXX:ARTISTS"] ?: tags["TPE1"])?.let { raw.artistNames = it }
|
||||||
tags["TSOP"]?.let { raw.artistSortNames = it }
|
tags["TSOP"]?.let { raw.artistSortNames = it }
|
||||||
|
|
||||||
// (Sort) Album artist
|
// (Sort) Album artist
|
||||||
|
|
|
@ -52,7 +52,6 @@ fun String.parseYear() = toIntOrNull()?.toDate()
|
||||||
fun String.parseTimestamp() = Date.from(this)
|
fun String.parseTimestamp() = Date.from(this)
|
||||||
|
|
||||||
private val SEPARATOR_REGEX_CACHE = mutableMapOf<String, Regex>()
|
private val SEPARATOR_REGEX_CACHE = mutableMapOf<String, Regex>()
|
||||||
private val ESCAPE_REGEX_CACHE = mutableMapOf<String, Regex>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fully parse a multi-value tag.
|
* 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.
|
// Try to cache compiled regexes for particular separator combinations.
|
||||||
val regex =
|
val regex =
|
||||||
synchronized(SEPARATOR_REGEX_CACHE) {
|
synchronized(SEPARATOR_REGEX_CACHE) {
|
||||||
SEPARATOR_REGEX_CACHE.getOrPut(separators) { Regex("[^\\\\][$separators]") }
|
SEPARATOR_REGEX_CACHE.getOrPut(separators) { Regex("[$separators]") }
|
||||||
}
|
}
|
||||||
|
|
||||||
val escape =
|
return regex.split(this).map { it.trim() }
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a multi-value tag into a [ReleaseType], handling separators in the process. */
|
/** Parse a multi-value tag into a [ReleaseType], handling separators in the process. */
|
||||||
|
|
|
@ -21,29 +21,29 @@ import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding
|
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.DialogViewHolder
|
||||||
import org.oxycblt.auxio.ui.recycler.ItemClickListener
|
import org.oxycblt.auxio.ui.recycler.ItemClickListener
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
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>() {
|
class ArtistChoiceAdapter(private val listener: ItemClickListener) : RecyclerView.Adapter<ArtistChoiceViewHolder>() {
|
||||||
private var genres = listOf<Genre>()
|
private var artists = listOf<Artist>()
|
||||||
|
|
||||||
override fun getItemCount() = genres.size
|
override fun getItemCount() = artists.size
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
GenreChoiceViewHolder.new(parent)
|
ArtistChoiceViewHolder.new(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
|
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
|
||||||
holder.bind(genres[position], listener)
|
holder.bind(artists[position], listener)
|
||||||
|
|
||||||
fun submitList(newGenres: List<Genre>) {
|
fun submitList(newArtists: List<Artist>) {
|
||||||
if (newGenres != genres) {
|
if (newArtists != artists) {
|
||||||
genres = newGenres
|
artists = newArtists
|
||||||
|
|
||||||
@Suppress("NotifyDataSetChanged")
|
@Suppress("NotifyDataSetChanged")
|
||||||
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.
|
* constraints.
|
||||||
*/
|
*/
|
||||||
class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : DialogViewHolder(binding.root) {
|
class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : DialogViewHolder(binding.root) {
|
||||||
fun bind(genre: Genre, listener: ItemClickListener) {
|
fun bind(artist: Artist, listener: ItemClickListener) {
|
||||||
binding.pickerImage.bind(genre)
|
binding.pickerImage.bind(artist)
|
||||||
binding.pickerName.text = genre.resolveName(binding.context)
|
binding.pickerName.text = artist.resolveName(binding.context)
|
||||||
binding.root.setOnClickListener {
|
binding.root.setOnClickListener {
|
||||||
listener.onItemClick(genre)
|
listener.onItemClick(artist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun new(parent: View) =
|
fun new(parent: View) =
|
||||||
GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -26,7 +26,9 @@ import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
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.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||||
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
|
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.ui.recycler.ItemClickListener
|
||||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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.
|
* ambiguous.
|
||||||
* @author OxygenCobalt
|
* @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 pickerModel: PickerViewModel by viewModels()
|
||||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||||
private val navModel: NavigationViewModel by activityViewModels()
|
private val navModel: NavigationViewModel by activityViewModels()
|
||||||
|
|
||||||
private val args: GenrePickerDialogArgs by navArgs()
|
private val args: ArtistPickerDialogArgs by navArgs()
|
||||||
private val adapter = GenreChoiceAdapter(this)
|
private val adapter = ArtistChoiceAdapter(this)
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
DialogMusicPickerBinding.inflate(inflater)
|
DialogMusicPickerBinding.inflate(inflater)
|
||||||
|
|
||||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||||
builder
|
builder
|
||||||
.setTitle(
|
.setTitle(R.string.lbl_artists)
|
||||||
when (args.pickerMode) {
|
|
||||||
PickerMode.GO -> R.string.lbl_go_genre
|
|
||||||
PickerMode.PLAY -> R.string.lbl_play_genre
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
|
||||||
pickerModel.setSongUid(args.songUid)
|
pickerModel.setSongUid(args.uid)
|
||||||
|
|
||||||
binding.pickerRecycler.adapter = adapter
|
binding.pickerRecycler.adapter = adapter
|
||||||
|
|
||||||
collectImmediately(pickerModel.currentSong) { song ->
|
collectImmediately(pickerModel.currentItem) { item ->
|
||||||
if (song != null) {
|
when (item) {
|
||||||
adapter.submitList(song.genres)
|
is Song -> adapter.submitList(item.artists)
|
||||||
} else {
|
is Album -> adapter.submitList(item.artists)
|
||||||
findNavController().navigateUp()
|
null -> findNavController().navigateUp()
|
||||||
|
else -> error("Invalid datatype: ${item::class.java}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,13 +81,14 @@ class GenrePickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(),
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: Item) {
|
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()
|
findNavController().navigateUp()
|
||||||
when (args.pickerMode) {
|
when (args.pickerMode) {
|
||||||
PickerMode.GO -> navModel.exploreNavigateTo(item)
|
PickerMode.SHOW -> navModel.exploreNavigateTo(item)
|
||||||
PickerMode.PLAY -> {
|
PickerMode.PLAY -> {
|
||||||
val song = unlikelyToBeNull(pickerModel.currentSong.value)
|
val currentItem = pickerModel.currentItem.value
|
||||||
playbackModel.playFromGenre(song, item)
|
check(currentItem is Song) { "PickerMode.PLAY is only allowed with Songs" }
|
||||||
|
playbackModel.playFromArtist(currentItem, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -22,5 +22,5 @@ package org.oxycblt.auxio.music.picker
|
||||||
*/
|
*/
|
||||||
enum class PickerMode {
|
enum class PickerMode {
|
||||||
PLAY,
|
PLAY,
|
||||||
GO
|
SHOW
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.picker
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
@ -32,20 +33,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
class PickerViewModel : ViewModel(), MusicStore.Callback {
|
class PickerViewModel : ViewModel(), MusicStore.Callback {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
|
|
||||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
private var _currentItem = MutableStateFlow<Music?>(null)
|
||||||
val currentSong: StateFlow<Song?> get() = _currentSong
|
val currentItem: StateFlow<Music?> = _currentItem
|
||||||
|
|
||||||
fun setSongUid(uid: Music.UID) {
|
fun setSongUid(uid: Music.UID) {
|
||||||
if (_currentSong.value?.uid == uid) return
|
if (_currentItem.value?.uid == uid) return
|
||||||
val library = unlikelyToBeNull(musicStore.library)
|
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?) {
|
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
val song = _currentSong.value
|
when (val item = currentItem.value) {
|
||||||
if (song != null) {
|
is Song -> {
|
||||||
_currentSong.value = library.sanitize(song)
|
_currentItem.value = library.sanitize(item)
|
||||||
|
}
|
||||||
|
is Album -> {
|
||||||
|
_currentItem.value = library.sanitize(item)
|
||||||
|
}
|
||||||
|
null -> {}
|
||||||
|
else -> error("Invalid datatype: ${item::class.java}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,9 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||||
|
|
||||||
override fun onBindingCreated(binding: DialogSeparatorsBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: DialogSeparatorsBinding, savedInstanceState: Bundle?) {
|
||||||
for (child in binding.separatorGroup.children) {
|
for (child in binding.separatorGroup.children) {
|
||||||
(child as MaterialCheckBox).isChecked = false
|
if (child is MaterialCheckBox) {
|
||||||
|
child.isChecked = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.separators?.forEach {
|
settings.separators?.forEach {
|
||||||
|
|
|
@ -30,6 +30,7 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.Sort
|
||||||
|
@ -223,7 +224,7 @@ class Indexer {
|
||||||
val buildStart = System.currentTimeMillis()
|
val buildStart = System.currentTimeMillis()
|
||||||
|
|
||||||
val albums = buildAlbums(songs)
|
val albums = buildAlbums(songs)
|
||||||
val artists = buildArtists(albums)
|
val artists = buildArtists(songs, albums)
|
||||||
val genres = buildGenres(songs)
|
val genres = buildGenres(songs)
|
||||||
|
|
||||||
// Make sure we finalize all the items now that they are fully built.
|
// Make sure we finalize all the items now that they are fully built.
|
||||||
|
@ -265,7 +266,7 @@ class Indexer {
|
||||||
yield()
|
yield()
|
||||||
|
|
||||||
// Note: We use a set here so we can eliminate effective duplicates of
|
// 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 songs = mutableSetOf<Song>()
|
||||||
val rawSongs = mutableListOf<Song.Raw>()
|
val rawSongs = mutableListOf<Song.Raw>()
|
||||||
|
|
||||||
|
@ -280,12 +281,10 @@ class Indexer {
|
||||||
|
|
||||||
metadataExtractor.finalize(rawSongs)
|
metadataExtractor.finalize(rawSongs)
|
||||||
|
|
||||||
val sorted = Sort(Sort.Mode.ByName, true).songs(songs)
|
|
||||||
|
|
||||||
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
|
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
|
||||||
|
|
||||||
// Ensure that sorting order is consistent so that grouping is also consistent.
|
// 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
|
* Group up songs AND albums into artists. This process seems weird (because it is), but
|
||||||
* where [buildAlbums] could not detect duplicates.
|
* 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> {
|
private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
|
||||||
val artists = mutableListOf<Artist>()
|
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
|
||||||
val albumsByArtist = albums.groupBy { it._rawArtist }
|
for (song in songs) {
|
||||||
|
for (rawArtist in song._rawArtists) {
|
||||||
for (entry in albumsByArtist) {
|
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
|
||||||
// The first album will suffice for template metadata.
|
|
||||||
artists.add(Artist(entry.key, entry.value))
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
logD("Successfully built ${artists.size} artists")
|
||||||
|
|
||||||
|
|
|
@ -119,7 +119,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
binding.playbackCover.bind(song)
|
binding.playbackCover.bind(song)
|
||||||
binding.playbackSong.text = song.resolveName(context)
|
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()
|
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.msToDs
|
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.state.RepeatMode
|
||||||
import org.oxycblt.auxio.playback.ui.StyledSeekBar
|
import org.oxycblt.auxio.playback.ui.StyledSeekBar
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||||
|
@ -87,11 +88,11 @@ class PlaybackPanelFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.playbackArtist.setOnClickListener {
|
binding.playbackArtist.setOnClickListener {
|
||||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) }
|
playbackModel.song.value?.let { showCurrentArtist() }
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.playbackAlbum.setOnClickListener {
|
binding.playbackAlbum.setOnClickListener {
|
||||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) }
|
playbackModel.song.value?.let { showCurrentAlbum() }
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.playbackSeekBar.callback = this
|
binding.playbackSeekBar.callback = this
|
||||||
|
@ -138,11 +139,11 @@ class PlaybackPanelFragment :
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_go_artist -> {
|
R.id.action_go_artist -> {
|
||||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) }
|
showCurrentArtist()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_go_album -> {
|
R.id.action_go_album -> {
|
||||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) }
|
showCurrentAlbum()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_song_detail -> {
|
R.id.action_song_detail -> {
|
||||||
|
@ -166,12 +167,11 @@ class PlaybackPanelFragment :
|
||||||
|
|
||||||
private fun updateSong(song: Song?) {
|
private fun updateSong(song: Song?) {
|
||||||
if (song == null) return
|
if (song == null) return
|
||||||
|
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
binding.playbackCover.bind(song)
|
binding.playbackCover.bind(song)
|
||||||
binding.playbackSong.text = song.resolveName(context)
|
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.playbackAlbum.text = song.album.resolveName(context)
|
||||||
binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
|
binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
|
||||||
}
|
}
|
||||||
|
@ -179,7 +179,6 @@ class PlaybackPanelFragment :
|
||||||
private fun updateParent(parent: MusicParent?) {
|
private fun updateParent(parent: MusicParent?) {
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
|
|
||||||
binding.playbackToolbar.subtitle =
|
binding.playbackToolbar.subtitle =
|
||||||
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
|
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
|
||||||
}
|
}
|
||||||
|
@ -202,4 +201,21 @@ class PlaybackPanelFragment :
|
||||||
private fun updateShuffled(isShuffled: Boolean) {
|
private fun updateShuffled(isShuffled: Boolean) {
|
||||||
requireBinding().playbackShuffle.isActivated = isShuffled
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -106,12 +106,14 @@ class PlaybackViewModel(application: Application) :
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Play a song from it's artist. */
|
/** Play a song from it's artist. */
|
||||||
fun playFromArtist(song: Song) {
|
fun playFromArtist(song: Song, artist: Artist) {
|
||||||
playbackManager.play(song, song.album.artist, settings, false)
|
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. */
|
/** Play a song from the specific genre that contains the song. */
|
||||||
fun playFromGenre(song: Song, genre: Genre) {
|
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)
|
playbackManager.play(song, genre, settings, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -140,7 +140,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
|
||||||
fun bind(item: Song, listener: QueueItemListener) {
|
fun bind(item: Song, listener: QueueItemListener) {
|
||||||
binding.songAlbumCover.bind(item)
|
binding.songAlbumCover.bind(item)
|
||||||
binding.songName.text = item.resolveName(binding.context)
|
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
|
binding.background.isInvisible = true
|
||||||
|
|
||||||
|
|
|
@ -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
|
// 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.
|
// consumers handle it, but that would break the notification display.
|
||||||
val title = song.resolveName(context)
|
val title = song.resolveName(context)
|
||||||
val artist = song.resolveIndividualArtistName(context)
|
val artist = song.resolveArtistContents(context)
|
||||||
val builder =
|
val builder =
|
||||||
MediaMetadataCompat.Builder()
|
MediaMetadataCompat.Builder()
|
||||||
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
.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_ALBUM, song.album.resolveName(context))
|
||||||
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
||||||
.putText(
|
.putText(
|
||||||
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
|
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
|
||||||
song.album.artist.resolveName(context)
|
song.album.resolveArtistContents(context)
|
||||||
)
|
)
|
||||||
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
|
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
|
||||||
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
|
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
|
||||||
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
|
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
|
||||||
.putText(
|
|
||||||
MediaMetadataCompat.METADATA_KEY_GENRE,
|
|
||||||
song.genres.joinToString { it.resolveName(context) }
|
|
||||||
)
|
|
||||||
.putText(
|
.putText(
|
||||||
METADATA_KEY_PARENT,
|
METADATA_KEY_PARENT,
|
||||||
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
|
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)
|
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
|
||||||
|
|
||||||
song.track?.let {
|
song.track?.let {
|
||||||
|
@ -202,7 +204,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
||||||
MediaDescriptionCompat.Builder()
|
MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(song.uid.toString())
|
.setMediaId(song.uid.toString())
|
||||||
.setTitle(song.resolveName(context))
|
.setTitle(song.resolveName(context))
|
||||||
.setSubtitle(song.resolveIndividualArtistName(context))
|
.setSubtitle(song.resolveArtistContents(context))
|
||||||
.setIconUri(song.album.coverUri)
|
.setIconUri(song.album.coverUri)
|
||||||
.setMediaUri(song.uri)
|
.setMediaUri(song.uri)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
@ -73,9 +73,10 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
||||||
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
|
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
|
||||||
|
|
||||||
// Starting in API 24, the subtext field changed semantics from being below the
|
// 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) {
|
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 {
|
} else {
|
||||||
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM))
|
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM))
|
||||||
}
|
}
|
||||||
|
|
|
@ -153,17 +153,19 @@ class SearchFragment :
|
||||||
is Song -> when (settings.libPlaybackMode) {
|
is Song -> when (settings.libPlaybackMode) {
|
||||||
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
MusicMode.SONGS -> playbackModel.playFromAll(item)
|
||||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
MusicMode.ALBUMS -> playbackModel.playFromAlbum(item)
|
||||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(item)
|
MusicMode.ARTISTS -> {
|
||||||
MusicMode.GENRES -> if (item.genres.size > 1) {
|
if (item.artists.size == 1) {
|
||||||
|
playbackModel.playFromArtist(item, item.artists[0])
|
||||||
|
} else {
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(
|
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)
|
is MusicParent -> navModel.exploreNavigateTo(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,8 +82,8 @@ class Settings(private val context: Context, private val callback: Callback? = n
|
||||||
|
|
||||||
fun Int.migratePlaybackMode() =
|
fun Int.migratePlaybackMode() =
|
||||||
when (this) {
|
when (this) {
|
||||||
IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
|
// Genre playback mode was retried in 3.0.0
|
||||||
IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES
|
IntegerTable.PLAYBACK_MODE_ALL_SONGS, IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.SONGS
|
||||||
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
|
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
|
||||||
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
|
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
|
||||||
else -> null
|
else -> null
|
||||||
|
@ -410,7 +410,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
|
||||||
Sort.fromIntCode(
|
Sort.fromIntCode(
|
||||||
inner.getInt(context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE)
|
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) {
|
set(value) {
|
||||||
inner.edit {
|
inner.edit {
|
||||||
putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode)
|
putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode)
|
||||||
|
|
|
@ -64,7 +64,7 @@ class NavigationViewModel : ViewModel() {
|
||||||
/** Navigate to an item's detail menu, whether a song/album/artist */
|
/** Navigate to an item's detail menu, whether a song/album/artist */
|
||||||
fun exploreNavigateTo(item: Music) {
|
fun exploreNavigateTo(item: Music) {
|
||||||
if (_exploreNavigationItem.value != null) {
|
if (_exploreNavigationItem.value != null) {
|
||||||
logD("Already navigation, not doing explore action")
|
logD("Already navigating, not doing explore action")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.music.picker.PickerMode
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||||
|
@ -65,7 +66,15 @@ abstract class MenuFragment<T : ViewBinding> : ViewBindingFragment<T>() {
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
}
|
}
|
||||||
R.id.action_go_artist -> {
|
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 -> {
|
R.id.action_go_album -> {
|
||||||
navModel.exploreNavigateTo(song.album)
|
navModel.exploreNavigateTo(song.album)
|
||||||
|
@ -110,7 +119,15 @@ abstract class MenuFragment<T : ViewBinding> : ViewBindingFragment<T>() {
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
}
|
}
|
||||||
R.id.action_go_artist -> {
|
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 -> {
|
else -> {
|
||||||
error("Unexpected menu item selected")
|
error("Unexpected menu item selected")
|
||||||
|
|
|
@ -41,7 +41,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
||||||
fun bind(item: Song, listener: MenuItemListener) {
|
fun bind(item: Song, listener: MenuItemListener) {
|
||||||
binding.songAlbumCover.bind(item)
|
binding.songAlbumCover.bind(item)
|
||||||
binding.songName.text = item.resolveName(binding.context)
|
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.songMenu.setOnClickListener { listener.onOpenMenu(item, it) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
listener.onOpenMenu(item, it)
|
listener.onOpenMenu(item, it)
|
||||||
|
@ -79,7 +79,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
fun bind(item: Album, listener: MenuItemListener) {
|
fun bind(item: Album, listener: MenuItemListener) {
|
||||||
binding.parentImage.bind(item)
|
binding.parentImage.bind(item)
|
||||||
binding.parentName.text = item.resolveName(binding.context)
|
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.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
listener.onOpenMenu(item, it)
|
listener.onOpenMenu(item, it)
|
||||||
|
@ -102,7 +102,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
object : SimpleItemCallback<Album>() {
|
object : SimpleItemCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.artist.rawName == newItem.artist.rawName &&
|
oldItem.areArtistContentsTheSame(newItem) &&
|
||||||
oldItem.releaseType == newItem.releaseType
|
oldItem.releaseType == newItem.releaseType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -118,12 +118,18 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
||||||
fun bind(item: Artist, listener: MenuItemListener) {
|
fun bind(item: Artist, listener: MenuItemListener) {
|
||||||
binding.parentImage.bind(item)
|
binding.parentImage.bind(item)
|
||||||
binding.parentName.text = item.resolveName(binding.context)
|
binding.parentName.text = item.resolveName(binding.context)
|
||||||
binding.parentInfo.text =
|
|
||||||
|
binding.parentInfo.text = if (item.songs.isNotEmpty()) {
|
||||||
binding.context.getString(
|
binding.context.getString(
|
||||||
R.string.fmt_two,
|
R.string.fmt_two,
|
||||||
binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size),
|
binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size),
|
||||||
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.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.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
listener.onOpenMenu(item, it)
|
listener.onOpenMenu(item, it)
|
||||||
|
|
|
@ -44,6 +44,9 @@ fun <T> unlikelyToBeNull(value: T?) =
|
||||||
/** Returns null if this value is 0. */
|
/** Returns null if this value is 0. */
|
||||||
fun Int.nonZeroOrNull() = if (this > 0) this else null
|
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]. */
|
/** Returns null if this value is not in [range]. */
|
||||||
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
|
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
|
||||||
|
|
||||||
|
|
|
@ -113,7 +113,7 @@ private fun RemoteViews.applyMeta(
|
||||||
applyCover(context, state)
|
applyCover(context, state)
|
||||||
|
|
||||||
setTextViewText(R.id.widget_song, state.song.resolveName(context))
|
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
|
return this
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,7 +72,14 @@
|
||||||
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
|
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
|
||||||
tools:ignore="RtlSymmetry" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -18,17 +18,18 @@
|
||||||
android:id="@+id/action_show_details"
|
android:id="@+id/action_show_details"
|
||||||
app:destination="@id/song_detail_dialog" />
|
app:destination="@id/song_detail_dialog" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/show_genre_picker_dialog"
|
android:id="@+id/action_pick_artist"
|
||||||
app:destination="@id/genre_picker_dialog" />
|
app:destination="@id/artist_picker_dialog" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
|
|
||||||
<dialog
|
<dialog
|
||||||
android:id="@+id/genre_picker_dialog"
|
android:id="@+id/artist_picker_dialog"
|
||||||
android:name="org.oxycblt.auxio.music.picker.GenrePickerDialog"
|
android:name="org.oxycblt.auxio.music.picker.ArtistPickerDialog"
|
||||||
android:label="genre_picker_dialog"
|
android:label="artist_picker_dialog"
|
||||||
tools:layout="@layout/dialog_music_picker">
|
tools:layout="@layout/dialog_music_picker">
|
||||||
<argument
|
<argument
|
||||||
android:name="songUid"
|
android:name="uid"
|
||||||
app:argType="org.oxycblt.auxio.music.Music$UID" />
|
app:argType="org.oxycblt.auxio.music.Music$UID" />
|
||||||
<argument
|
<argument
|
||||||
android:name="pickerMode"
|
android:name="pickerMode"
|
||||||
|
|
|
@ -80,14 +80,12 @@
|
||||||
<item>@string/set_playback_mode_all</item>
|
<item>@string/set_playback_mode_all</item>
|
||||||
<item>@string/set_playback_mode_artist</item>
|
<item>@string/set_playback_mode_artist</item>
|
||||||
<item>@string/set_playback_mode_album</item>
|
<item>@string/set_playback_mode_album</item>
|
||||||
<item>@string/set_playback_mode_genre</item>
|
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<integer-array name="values_library_song_playback_mode">
|
<integer-array name="values_library_song_playback_mode">
|
||||||
<item>@integer/play_mode_songs</item>
|
<item>@integer/music_mode_songs</item>
|
||||||
<item>@integer/play_mode_artist</item>
|
<item>@integer/music_mode_artist</item>
|
||||||
<item>@integer/play_mode_album</item>
|
<item>@integer/music_mode_album</item>
|
||||||
<item>@integer/play_mode_genre</item>
|
|
||||||
</integer-array>
|
</integer-array>
|
||||||
|
|
||||||
<string-array name="entries_detail_song_playback_mode">
|
<string-array name="entries_detail_song_playback_mode">
|
||||||
|
@ -95,15 +93,13 @@
|
||||||
<item>@string/set_playback_mode_all</item>
|
<item>@string/set_playback_mode_all</item>
|
||||||
<item>@string/set_playback_mode_artist</item>
|
<item>@string/set_playback_mode_artist</item>
|
||||||
<item>@string/set_playback_mode_album</item>
|
<item>@string/set_playback_mode_album</item>
|
||||||
<item>@string/set_playback_mode_genre</item>
|
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<integer-array name="values_detail_song_playback_mode">
|
<integer-array name="values_detail_song_playback_mode">
|
||||||
<item>@integer/play_mode_none</item>
|
<item>@integer/music_mode_none</item>
|
||||||
<item>@integer/play_mode_songs</item>
|
<item>@integer/music_mode_songs</item>
|
||||||
<item>@integer/play_mode_artist</item>
|
<item>@integer/music_mode_artist</item>
|
||||||
<item>@integer/play_mode_album</item>
|
<item>@integer/music_mode_album</item>
|
||||||
<item>@integer/play_mode_genre</item>
|
|
||||||
</integer-array>
|
</integer-array>
|
||||||
|
|
||||||
<string-array name="entries_replay_gain">
|
<string-array name="entries_replay_gain">
|
||||||
|
@ -126,11 +122,10 @@
|
||||||
<integer name="bar_action_repeat">0xA11A</integer>
|
<integer name="bar_action_repeat">0xA11A</integer>
|
||||||
<integer name="bar_action_shuffle">0xA11B</integer>
|
<integer name="bar_action_shuffle">0xA11B</integer>
|
||||||
|
|
||||||
<integer name="play_mode_none">-2147483648</integer>
|
<integer name="music_mode_none">-2147483648</integer>
|
||||||
<integer name="play_mode_genre">0xA108</integer>
|
<integer name="music_mode_artist">0xA109</integer>
|
||||||
<integer name="play_mode_artist">0xA109</integer>
|
<integer name="music_mode_album">0xA10A</integer>
|
||||||
<integer name="play_mode_album">0xA10A</integer>
|
<integer name="music_mode_songs">0xA10B</integer>
|
||||||
<integer name="play_mode_songs">0xA10B</integer>
|
|
||||||
|
|
||||||
<integer name="replay_gain_track">0xA111</integer>
|
<integer name="replay_gain_track">0xA111</integer>
|
||||||
<integer name="replay_gain_album">0xA112</integer>
|
<integer name="replay_gain_album">0xA112</integer>
|
||||||
|
|
|
@ -105,8 +105,6 @@
|
||||||
<string name="lbl_go_genre">Go to genre</string>
|
<string name="lbl_go_genre">Go to genre</string>
|
||||||
<string name="lbl_go_artist">Go to artist</string>
|
<string name="lbl_go_artist">Go to artist</string>
|
||||||
<string name="lbl_go_album">Go to album</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_song_detail">View properties</string>
|
||||||
|
|
||||||
<string name="lbl_props">Song properties</string>
|
<string name="lbl_props">Song properties</string>
|
||||||
|
@ -182,7 +180,7 @@
|
||||||
<!-- Skip to next (song) -->
|
<!-- Skip to next (song) -->
|
||||||
<string name="set_bar_action_next">Skip to next</string>
|
<string name="set_bar_action_next">Skip to next</string>
|
||||||
<string name="set_bar_action_repeat">Repeat mode</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_action">Use alternate notification action</string>
|
||||||
<string name="set_alt_repeat">Prefer repeat mode action</string>
|
<string name="set_alt_repeat">Prefer repeat mode action</string>
|
||||||
<string name="set_alt_shuffle">Prefer shuffle 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_none">Play from shown item</string>
|
||||||
<string name="set_playback_mode_all">Play from all songs</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_album">Play from album</string>
|
||||||
<string name="set_playback_mode_artist">@string/lbl_play_artist</string>
|
<string name="set_playback_mode_artist">Play from artist</string>
|
||||||
<string name="set_playback_mode_genre">@string/lbl_play_genre</string>
|
|
||||||
<string name="set_keep_shuffle">Remember shuffle</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_keep_shuffle_desc">Keep shuffle on when playing a new song</string>
|
||||||
<string name="set_rewind_prev">Rewind before skipping back</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">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_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">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_comma">Comma (,)</string>
|
||||||
<string name="set_separators_semicolon">Semicolon (;)</string>
|
<string name="set_separators_semicolon">Semicolon (;)</string>
|
||||||
<string name="set_separators_slash">Slash (/)</string>
|
<string name="set_separators_slash">Slash (/)</string>
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
<PreferenceCategory app:title="@string/set_behavior">
|
<PreferenceCategory app:title="@string/set_behavior">
|
||||||
|
|
||||||
<org.oxycblt.auxio.settings.IntListPreference
|
<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:entries="@array/entries_library_song_playback_mode"
|
||||||
app:entryValues="@array/values_library_song_playback_mode"
|
app:entryValues="@array/values_library_song_playback_mode"
|
||||||
app:key="@string/set_key_library_song_playback_mode"
|
app:key="@string/set_key_library_song_playback_mode"
|
||||||
|
@ -99,7 +99,7 @@
|
||||||
app:useSimpleSummaryProvider="true" />
|
app:useSimpleSummaryProvider="true" />
|
||||||
|
|
||||||
<org.oxycblt.auxio.settings.IntListPreference
|
<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:entries="@array/entries_detail_song_playback_mode"
|
||||||
app:entryValues="@array/values_detail_song_playback_mode"
|
app:entryValues="@array/values_detail_song_playback_mode"
|
||||||
app:key="@string/set_key_detail_song_playback_mode"
|
app:key="@string/set_key_detail_song_playback_mode"
|
||||||
|
|
|
@ -9,7 +9,7 @@ buildscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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 "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
|
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
|
||||||
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.10.0"
|
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.10.0"
|
||||||
|
|
Loading…
Reference in a new issue