sort: add duration and count sorts

Add sorting modes for duration and song count.

This was requested previously in the now-closed UI/UX changes
megathread, however I have only gotten to it now.
This commit is contained in:
OxygenCobalt 2022-05-20 18:33:36 -06:00
parent 059652d2f1
commit 28feebcec3
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
28 changed files with 242 additions and 93 deletions

View file

@ -92,10 +92,14 @@ object IntegerTable {
const val SORT_BY_ALBUM = 0xA10E const val SORT_BY_ALBUM = 0xA10E
/** Sort.ByYear */ /** Sort.ByYear */
const val SORT_BY_YEAR = 0xA10F const val SORT_BY_YEAR = 0xA10F
/** Sort.ByDuration */
const val SORT_BY_DURATION = 0xA114
/** Sort.ByCount */
const val SORT_BY_COUNT = 0xA115
/** Sort.ByDisc */ /** Sort.ByDisc */
const val SORT_BY_DISC = 0xA114 const val SORT_BY_DISC = 0xA116
/** Sort.ByTrack */ /** Sort.ByTrack */
const val SORT_BY_TRACK = 0xA115 const val SORT_BY_TRACK = 0xA117
/** ReplayGainMode.Off */ /** ReplayGainMode.Off */
const val REPLAY_GAIN_MODE_OFF = 0xA110 const val REPLAY_GAIN_MODE_OFF = 0xA110

View file

@ -86,7 +86,7 @@ class MainActivity : AppCompatActivity() {
if (action == Intent.ACTION_VIEW && !isConsumed) { if (action == Intent.ACTION_VIEW && !isConsumed) {
// Mark the intent as used so this does not fire again // Mark the intent as used so this does not fire again
intent.putExtra(KEY_INTENT_USED, true) intent.putExtra(KEY_INTENT_USED, true)
intent.data?.let { fileUri -> playbackModel.playWithUri(fileUri, this) } intent.data?.let { fileUri -> playbackModel.play(fileUri, this) }
} }
} }
} }

View file

@ -89,7 +89,7 @@ class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
if (item is Song) { if (item is Song) {
playbackModel.playSong(item, PlaybackMode.IN_ALBUM) playbackModel.play(item, PlaybackMode.IN_ALBUM)
} }
} }
@ -98,11 +98,11 @@ class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
} }
override fun onPlayParent() { override fun onPlayParent() {
playbackModel.playAlbum(unlikelyToBeNull(detailModel.currentAlbum.value), false) playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), false)
} }
override fun onShuffleParent() { override fun onShuffleParent() {
playbackModel.playAlbum(unlikelyToBeNull(detailModel.currentAlbum.value), true) playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), true)
} }
override fun onShowSortMenu(anchor: View) { override fun onShowSortMenu(anchor: View) {

View file

@ -74,7 +74,7 @@ class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
when (item) { when (item) {
is Song -> playbackModel.playSong(item, PlaybackMode.IN_ARTIST) is Song -> playbackModel.play(item, PlaybackMode.IN_ARTIST)
is Album -> navModel.exploreNavigateTo(item) is Album -> navModel.exploreNavigateTo(item)
} }
} }
@ -84,11 +84,11 @@ class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
} }
override fun onPlayParent() { override fun onPlayParent() {
playbackModel.playArtist(unlikelyToBeNull(detailModel.currentArtist.value), false) playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), false)
} }
override fun onShuffleParent() { override fun onShuffleParent() {
playbackModel.playArtist(unlikelyToBeNull(detailModel.currentArtist.value), true) playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), true)
} }
override fun onShowSortMenu(anchor: View) { override fun onShowSortMenu(anchor: View) {

View file

@ -71,7 +71,7 @@ class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
when (item) { when (item) {
is Song -> playbackModel.playSong(item, PlaybackMode.IN_GENRE) is Song -> playbackModel.play(item, PlaybackMode.IN_GENRE)
is Album -> is Album ->
findNavController() findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id)) .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
@ -83,11 +83,11 @@ class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
} }
override fun onPlayParent() { override fun onPlayParent() {
playbackModel.playGenre(unlikelyToBeNull(detailModel.currentGenre.value), false) playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), false)
} }
override fun onShuffleParent() { override fun onShuffleParent() {
playbackModel.playGenre(unlikelyToBeNull(detailModel.currentGenre.value), true) playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), true)
} }
override fun onShowSortMenu(anchor: View) { override fun onShowSortMenu(anchor: View) {

View file

@ -137,7 +137,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
R.string.fmt_three, R.string.fmt_three,
item.year?.toString() ?: context.getString(R.string.def_date), item.year?.toString() ?: context.getString(R.string.def_date),
context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size), context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size),
item.totalDuration) item.durationSecs.formatDuration(false))
} }
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
@ -161,7 +161,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
oldItem.artist.rawName == newItem.artist.rawName && oldItem.artist.rawName == newItem.artist.rawName &&
oldItem.year == newItem.year && oldItem.year == newItem.year &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.totalDuration == newItem.totalDuration oldItem.durationSecs == newItem.durationSecs
} }
} }
} }
@ -215,7 +215,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
} }
binding.songName.textSafe = item.resolveName(binding.context) binding.songName.textSafe = item.resolveName(binding.context)
binding.songDuration.textSafe = item.seconds.formatDuration(false) binding.songDuration.textSafe = item.durationSecs.formatDuration(false)
binding.root.apply { binding.root.apply {
setOnClickListener { listener.onItemClick(item) } setOnClickListener { listener.onItemClick(item) }
@ -245,7 +245,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
val DIFFER = val DIFFER =
object : SimpleItemCallback<Song>() { object : SimpleItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) = override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.duration == newItem.duration oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
} }
} }
} }

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.SimpleItemCallback import org.oxycblt.auxio.ui.SimpleItemCallback
import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe import org.oxycblt.auxio.util.textSafe
@ -117,7 +118,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
binding.detailName.textSafe = item.resolveName(binding.context) binding.detailName.textSafe = item.resolveName(binding.context)
binding.detailSubhead.textSafe = binding.detailSubhead.textSafe =
binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size) binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)
binding.detailInfo.textSafe = item.totalDuration binding.detailInfo.textSafe = item.durationSecs.formatDuration(false)
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
} }
@ -137,7 +138,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
override fun areItemsTheSame(oldItem: Genre, newItem: Genre) = override fun areItemsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.totalDuration == newItem.totalDuration oldItem.durationSecs == newItem.durationSecs
} }
} }
} }

View file

@ -58,8 +58,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author OxygenCobalt * @author OxygenCobalt
* *
* TODO: Make tabs invisible when there is only one * TODO: Make tabs invisible when there is only one
*
* TODO: Add duration and song count sorts
*/ */
class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuItemClickListener { class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuItemClickListener {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
@ -79,6 +77,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
binding.homePager.apply { binding.homePager.apply {
adapter = HomePagerAdapter() adapter = HomePagerAdapter()
// We know that there will only be a fixed amount of tabs, so we manually set this // We know that there will only be a fixed amount of tabs, so we manually set this
// limit to that. This also prevents the appbar lift state from being confused during // limit to that. This also prevents the appbar lift state from being confused during
// page transitions. // page transitions.
@ -138,9 +137,8 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
.getSortForDisplay(unlikelyToBeNull(homeModel.currentTab.value)) .getSortForDisplay(unlikelyToBeNull(homeModel.currentTab.value))
.ascending(item.isChecked))) .ascending(item.isChecked)))
} }
// Sorting option was selected, mark it as selected and update the mode
else -> { else -> {
// Sorting option was selected, mark it as selected and update the mode
item.isChecked = true item.isChecked = true
homeModel.updateCurrentSort( homeModel.updateCurrentSort(
unlikelyToBeNull( unlikelyToBeNull(
@ -175,7 +173,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
val binding = requireBinding() val binding = requireBinding()
when (tab) { when (tab) {
DisplayMode.SHOW_SONGS -> { DisplayMode.SHOW_SONGS -> {
updateSortMenu(sortItem, tab) updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_count }
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list
} }
DisplayMode.SHOW_ALBUMS -> { DisplayMode.SHOW_ALBUMS -> {
@ -183,11 +181,21 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list
} }
DisplayMode.SHOW_ARTISTS -> { DisplayMode.SHOW_ARTISTS -> {
updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc } updateSortMenu(sortItem, tab) { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_artist_list binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_artist_list
} }
DisplayMode.SHOW_GENRES -> { DisplayMode.SHOW_GENRES -> {
updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc } updateSortMenu(sortItem, tab) { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_genre_list binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_genre_list
} }
} }
@ -196,7 +204,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
private fun updateSortMenu( private fun updateSortMenu(
item: MenuItem, item: MenuItem,
displayMode: DisplayMode, displayMode: DisplayMode,
isVisible: (Int) -> Boolean = { true } isVisible: (Int) -> Boolean
) { ) {
val toHighlight = homeModel.getSortForDisplay(displayMode) val toHighlight = homeModel.getSortForDisplay(displayMode)

View file

@ -91,6 +91,9 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback
_shouldRecreateTabs.value = false _shouldRecreateTabs.value = false
} }
/**
* Get the specific sort for the given [DisplayMode].
*/
fun getSortForDisplay(displayMode: DisplayMode): Sort { fun getSortForDisplay(displayMode: DisplayMode): Sort {
return when (displayMode) { return when (displayMode) {
DisplayMode.SHOW_SONGS -> settingsManager.libSongSort DisplayMode.SHOW_SONGS -> settingsManager.libSongSort

View file

@ -30,6 +30,7 @@ import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -62,6 +63,12 @@ class AlbumListFragment : HomeListFragment<Album>() {
// Year -> Use Full Year // Year -> Use Full Year
is Sort.ByYear -> album.year?.toString() is Sort.ByYear -> album.year?.toString()
// Duration -> Use formatted duration
is Sort.ByDuration -> album.durationSecs.formatDuration(false)
// Count -> Use song count
is Sort.ByCount -> album.songs.size.toString()
// Unsupported sort, error gracefully // Unsupported sort, error gracefully
else -> null else -> null
} }

View file

@ -23,11 +23,14 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.ui.ArtistViewHolder import org.oxycblt.auxio.ui.ArtistViewHolder
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -46,8 +49,24 @@ class ArtistListFragment : HomeListFragment<Artist>() {
homeModel.artists.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) } homeModel.artists.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
} }
override fun getPopup(pos: Int) = override fun getPopup(pos: Int): String? {
unlikelyToBeNull(homeModel.artists.value)[pos].sortName?.run { first().uppercase() } val artist = unlikelyToBeNull(homeModel.artists.value)[pos]
// Change how we display the popup depending on the mode.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ARTISTS)) {
// By Name -> Use Name
is Sort.ByName -> artist.sortName?.run { first().uppercase() }
// Duration -> Use formatted duration
is Sort.ByDuration -> artist.durationSecs.formatDuration(false)
// Count -> Use song count
is Sort.ByCount -> artist.songs.size.toString()
// Unsupported sort, error gracefully
else -> null
}
}
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
check(item is Music) check(item is Music)

View file

@ -22,12 +22,15 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.GenreViewHolder import org.oxycblt.auxio.ui.GenreViewHolder
import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -46,8 +49,24 @@ class GenreListFragment : HomeListFragment<Genre>() {
homeModel.genres.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) } homeModel.genres.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
} }
override fun getPopup(pos: Int) = override fun getPopup(pos: Int): String? {
unlikelyToBeNull(homeModel.genres.value)[pos].sortName?.run { first().uppercase() } val genre = unlikelyToBeNull(homeModel.genres.value)[pos]
// Change how we display the popup depending on the mode.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_GENRES)) {
// By Name -> Use Name
is Sort.ByName -> genre.sortName?.run { first().uppercase() }
// Duration -> Use formatted duration
is Sort.ByDuration -> genre.durationSecs.formatDuration(false)
// Count -> Use song count
is Sort.ByCount -> genre.songs.size.toString()
// Unsupported sort, error gracefully
else -> null
}
}
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
check(item is Music) check(item is Music)

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -66,15 +67,17 @@ class SongListFragment : HomeListFragment<Song>() {
// Year -> Use Full Year // Year -> Use Full Year
is Sort.ByYear -> song.album.year?.toString() is Sort.ByYear -> song.album.year?.toString()
// Unreachable state // Duration -> Use formatted duration
is Sort.ByDisc, is Sort.ByDuration -> song.durationSecs.formatDuration(false)
is Sort.ByTrack -> null
// Unsupported sort, error gracefully
else -> null
} }
} }
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
check(item is Song) check(item is Song)
playbackModel.playSong(item) playbackModel.play(item)
} }
override fun onOpenMenu(item: Item, anchor: View) { override fun onOpenMenu(item: Item, anchor: View) {

View file

@ -260,7 +260,7 @@ object Indexer {
it._mediaStoreArtistName to it._mediaStoreArtistName to
it._mediaStoreAlbumArtistName to it._mediaStoreAlbumArtistName to
it.track to it.track to
it.duration it.durationMs
} }
.toMutableList() .toMutableList()

View file

@ -26,7 +26,6 @@ import android.provider.MediaStore
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
// --- MUSIC MODELS --- // --- MUSIC MODELS ---
@ -53,6 +52,10 @@ 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>
/** The formatted total duration of this genre */
val durationSecs: Long
get() = songs.sumOf { it.durationSecs }
} }
/** The data object for a song. */ /** The data object for a song. */
@ -61,7 +64,7 @@ data class Song(
/** The file name of this song, excluding the full path. */ /** The file name of this song, excluding the full path. */
val fileName: String, val fileName: String,
/** The total duration of this song, in millis. */ /** The total duration of this song, in millis. */
val duration: Long, val durationMs: Long,
/** The track number of this song, null if there isn't any. */ /** The track number of this song, null if there isn't any. */
val track: Int?, val track: Int?,
/** The disc number of this song, null if there isn't any. */ /** The disc number of this song, null if there isn't any. */
@ -85,7 +88,7 @@ data class Song(
result = 31 * result + album.rawName.hashCode() result = 31 * result + album.rawName.hashCode()
result = 31 * result + album.artist.rawName.hashCode() result = 31 * result + album.artist.rawName.hashCode()
result = 31 * result + (track ?: 0) result = 31 * result + (track ?: 0)
result = 31 * result + duration.hashCode() result = 31 * result + durationMs.hashCode()
return result return result
} }
@ -99,8 +102,8 @@ data class Song(
get() = get() =
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, _mediaStoreId) ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, _mediaStoreId)
/** The duration of this song, in seconds (rounded down) */ /** The duration of this song, in seconds (rounded down) */
val seconds: Long val durationSecs: Long
get() = duration / 1000 get() = durationMs / 1000
private var _album: Album? = null private var _album: Album? = null
/** The album of this song. */ /** The album of this song. */
@ -190,10 +193,6 @@ data class Album(
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
/** The formatted total duration of this album */
val totalDuration: String
get() = songs.sumOf { it.seconds }.formatDuration(false)
private var _artist: Artist? = null private var _artist: Artist? = null
/** The parent artist of this album. */ /** The parent artist of this album. */
val artist: Artist val artist: Artist
@ -256,10 +255,6 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
override fun resolveName(context: Context) = override fun resolveName(context: Context) =
rawName?.genreNameCompat ?: context.getString(R.string.def_genre) rawName?.genreNameCompat ?: context.getString(R.string.def_genre)
/** The formatted total duration of this genre */
val totalDuration: String
get() = songs.sumOf { it.seconds }.formatDuration(false)
} }
/** /**

View file

@ -34,6 +34,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.hardRestart import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
/** /**
@ -144,7 +145,7 @@ class ExcludedDialog :
return getRootPath() + "/" + typeAndPath.last() return getRootPath() + "/" + typeAndPath.last()
} }
logD("Unsupported volume ${typeAndPath[0]}") logW("Unsupported volume ${typeAndPath[0]}")
return null return null
} }

View file

@ -78,11 +78,11 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
} }
} }
binding.playbackSkipPrev?.setOnClickListener { playbackModel.skipPrev() } binding.playbackSkipPrev?.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() } binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
binding.playbackSkipNext?.setOnClickListener { playbackModel.skipNext() } binding.playbackSkipNext?.setOnClickListener { playbackModel.next() }
// Deliberately override the progress bar color [in a Lollipop-friendly way] so that // Deliberately override the progress bar color [in a Lollipop-friendly way] so that
// we use colorSecondary instead of colorSurfaceVariant. This is because // we use colorSecondary instead of colorSurfaceVariant. This is because
@ -107,7 +107,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding.playbackCover.bindAlbumCover(song) binding.playbackCover.bindAlbumCover(song)
binding.playbackSong.textSafe = song.resolveName(context) binding.playbackSong.textSafe = song.resolveName(context)
binding.playbackInfo.textSafe = song.resolveIndividualArtistName(context) binding.playbackInfo.textSafe = song.resolveIndividualArtistName(context)
binding.playbackProgressBar.max = song.seconds.toInt() binding.playbackProgressBar.max = song.durationSecs.toInt()
} }
} }

View file

@ -108,7 +108,7 @@ class PlaybackPanelFragment :
} }
binding.playbackRepeat.setOnClickListener { playbackModel.incrementRepeatMode() } binding.playbackRepeat.setOnClickListener { playbackModel.incrementRepeatMode() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.skipPrev() } binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.apply { binding.playbackPlayPause.apply {
// Abuse the play/pause FAB (see style definition for more info) // Abuse the play/pause FAB (see style definition for more info)
@ -116,7 +116,7 @@ class PlaybackPanelFragment :
setOnClickListener { playbackModel.invertPlaying() } setOnClickListener { playbackModel.invertPlaying() }
} }
binding.playbackSkipNext.setOnClickListener { playbackModel.skipNext() } binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() } binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
binding.playbackSeekBar.apply {} binding.playbackSeekBar.apply {}
@ -162,7 +162,7 @@ class PlaybackPanelFragment :
override fun onStopTrackingTouch(slider: Slider) { override fun onStopTrackingTouch(slider: Slider) {
requireBinding().playbackPosition.isActivated = false requireBinding().playbackPosition.isActivated = false
playbackModel.setPosition(slider.value.toLong()) playbackModel.seekTo(slider.value.toLong())
} }
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
@ -182,7 +182,7 @@ class PlaybackPanelFragment :
binding.playbackAlbum.textSafe = song.album.resolveName(context) binding.playbackAlbum.textSafe = song.album.resolveName(context)
// Normally if a song had a duration // Normally if a song had a duration
val seconds = song.seconds val seconds = song.durationSecs
binding.playbackDuration.textSafe = seconds.formatDuration(false) binding.playbackDuration.textSafe = seconds.formatDuration(false)
binding.playbackSeekBar.apply { binding.playbackSeekBar.apply {
isEnabled = seconds > 0L isEnabled = seconds > 0L

View file

@ -99,7 +99,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* Play a [song] with the [mode] specified. [mode] will default to the preferred song playback * Play a [song] with the [mode] specified. [mode] will default to the preferred song playback
* mode of the user if not specified. * mode of the user if not specified.
*/ */
fun playSong(song: Song, mode: PlaybackMode = settingsManager.songPlaybackMode) { fun play(song: Song, mode: PlaybackMode = settingsManager.songPlaybackMode) {
playbackManager.play(song, mode) playbackManager.play(song, mode)
} }
@ -107,10 +107,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* Play an [album]. * Play an [album].
* @param shuffled Whether to shuffle the new queue * @param shuffled Whether to shuffle the new queue
*/ */
fun playAlbum(album: Album, shuffled: Boolean) { fun play(album: Album, shuffled: Boolean) {
if (album.songs.isEmpty()) { if (album.songs.isEmpty()) {
logE("Album is empty, Not playing") logE("Album is empty, Not playing")
return return
} }
@ -121,10 +120,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* Play an [artist]. * Play an [artist].
* @param shuffled Whether to shuffle the new queue * @param shuffled Whether to shuffle the new queue
*/ */
fun playArtist(artist: Artist, shuffled: Boolean) { fun play(artist: Artist, shuffled: Boolean) {
if (artist.songs.isEmpty()) { if (artist.songs.isEmpty()) {
logE("Artist is empty, Not playing") logE("Artist is empty, Not playing")
return return
} }
@ -135,10 +133,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
* Play a [genre]. * Play a [genre].
* @param shuffled Whether to shuffle the new queue * @param shuffled Whether to shuffle the new queue
*/ */
fun playGenre(genre: Genre, shuffled: Boolean) { fun play(genre: Genre, shuffled: Boolean) {
if (genre.songs.isEmpty()) { if (genre.songs.isEmpty()) {
logE("Genre is empty, Not playing") logE("Genre is empty, Not playing")
return return
} }
@ -148,22 +145,21 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
/** /**
* Play using a file [uri]. This will not play instantly during the initial startup sequence. * Play using a file [uri]. This will not play instantly during the initial startup sequence.
*/ */
fun playWithUri(uri: Uri, context: Context) { fun play(uri: Uri, context: Context) {
// Check if everything is already running to run the URI play // Check if everything is already running to run the URI play
if (playbackManager.isInitialized && musicStore.library != null) { if (playbackManager.isInitialized && musicStore.library != null) {
playWithUriInternal(uri, context) playUriImpl(uri, context)
} else { } else {
logD("Cant play this URI right now, waiting") logD("Cant play this URI right now, waiting")
intentUri = uri intentUri = uri
} }
} }
/** Play with a file URI. This is called after [playWithUri] once its deemed safe to do so. */ /** Play with a file URI. This is called after [play] once its deemed safe to do so. */
private fun playWithUriInternal(uri: Uri, context: Context) { private fun playUriImpl(uri: Uri, context: Context) {
logD("Playing with uri $uri") logD("Playing with uri $uri")
val library = musicStore.library ?: return val library = musicStore.library ?: return
library.findSongForUri(uri, context.contentResolver)?.let { song -> playSong(song) } library.findSongForUri(uri, context.contentResolver)?.let { song -> play(song) }
} }
/** Shuffle all songs */ /** Shuffle all songs */
@ -174,19 +170,19 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// --- POSITION FUNCTIONS --- // --- POSITION FUNCTIONS ---
/** Update the position and push it to [PlaybackStateManager] */ /** Update the position and push it to [PlaybackStateManager] */
fun setPosition(progress: Long) { fun seekTo(positionSecs: Long) {
playbackManager.seekTo(progress * 1000) playbackManager.seekTo(positionSecs * 1000)
} }
// --- QUEUE FUNCTIONS --- // --- QUEUE FUNCTIONS ---
/** Skip to the next song. */ /** Skip to the next song. */
fun skipNext() { fun next() {
playbackManager.next() playbackManager.next()
} }
/** Skip to the previous song. */ /** Skip to the previous song. */
fun skipPrev() { fun prev() {
playbackManager.prev() playbackManager.prev()
} }
@ -271,14 +267,14 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
/** /**
* Restore playback on startup. This can do one of two things: * Restore playback on startup. This can do one of two things:
* - Play a file intent that was given by MainActivity in [playWithUri] * - Play a file intent that was given by MainActivity in [play]
* - Restore the last playback state if there is no active file intent. * - Restore the last playback state if there is no active file intent.
*/ */
fun setupPlayback(context: Context) { fun setupPlayback(context: Context) {
val intentUri = intentUri val intentUri = intentUri
if (intentUri != null) { if (intentUri != null) {
playWithUriInternal(intentUri, context) playUriImpl(intentUri, context)
// Remove the uri after finishing the calls so that this does not fire again. // Remove the uri after finishing the calls so that this does not fire again.
this.intentUri = null this.intentUri = null
} else if (!playbackManager.isInitialized) { } else if (!playbackManager.isInitialized) {

View file

@ -277,7 +277,7 @@ class PlaybackStateManager private constructor() {
*/ */
fun synchronizePosition(positionMs: Long) { fun synchronizePosition(positionMs: Long) {
// Don't accept any bugged positions that are over the duration of the song. // Don't accept any bugged positions that are over the duration of the song.
val maxDuration = song?.duration ?: -1 val maxDuration = song?.durationMs ?: -1
if (positionMs <= maxDuration) { if (positionMs <= maxDuration) {
this.positionMs = positionMs this.positionMs = positionMs
notifyPositionChanged() notifyPositionChanged()

View file

@ -100,7 +100,7 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genre.resolveName(context)) .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genre.resolveName(context))
.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, song.track?.toLong() ?: 0L) .putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, song.track?.toLong() ?: 0L)
.putText(MediaMetadataCompat.METADATA_KEY_DATE, song.album.year?.toString()) .putText(MediaMetadataCompat.METADATA_KEY_DATE, song.album.year?.toString())
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
.putText( .putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
song.album.albumCoverUri.toString()) song.album.albumCoverUri.toString())

View file

@ -134,7 +134,7 @@ class SearchFragment :
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
when (item) { when (item) {
is Song -> playbackModel.playSong(item) is Song -> playbackModel.play(item)
is MusicParent -> navModel.exploreNavigateTo(item) is MusicParent -> navModel.exploreNavigateTo(item)
} }
} }

View file

@ -65,7 +65,8 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
binding.aboutSongCount.textSafe = getString(R.string.fmt_songs_loaded, songs.size) binding.aboutSongCount.textSafe = getString(R.string.fmt_songs_loaded, songs.size)
binding.aboutTotalDuration.textSafe = binding.aboutTotalDuration.textSafe =
getString( getString(
R.string.fmt_total_duration, songs.sumOf { it.seconds }.formatDuration(false)) R.string.fmt_total_duration,
songs.sumOf { it.durationSecs }.formatDuration(false))
} }
homeModel.albums.observe(viewLifecycleOwner) { albums -> homeModel.albums.observe(viewLifecycleOwner) { albums ->

View file

@ -129,17 +129,17 @@ class ActionMenu(
when (id) { when (id) {
R.id.action_play -> { R.id.action_play -> {
when (data) { when (data) {
is Album -> playbackModel.playAlbum(data, false) is Album -> playbackModel.play(data, false)
is Artist -> playbackModel.playArtist(data, false) is Artist -> playbackModel.play(data, false)
is Genre -> playbackModel.playGenre(data, false) is Genre -> playbackModel.play(data, false)
else -> {} else -> {}
} }
} }
R.id.action_shuffle -> { R.id.action_shuffle -> {
when (data) { when (data) {
is Album -> playbackModel.playAlbum(data, true) is Album -> playbackModel.play(data, true)
is Artist -> playbackModel.playArtist(data, true) is Artist -> playbackModel.play(data, true)
is Genre -> playbackModel.playGenre(data, true) is Genre -> playbackModel.play(data, true)
else -> {} else -> {}
} }
} }

View file

@ -205,6 +205,74 @@ sealed class Sort(open val isAscending: Boolean) {
} }
} }
/** Sort by the duration of the item. Supports all items. */
class ByDuration(override val isAscending: Boolean) : Sort(isAscending) {
override val sortIntCode: Int
get() = IntegerTable.SORT_BY_DURATION
override val itemId: Int
get() = R.id.option_sort_duration
override fun songsInPlace(songs: MutableList<Song>) {
songs.sortWith(
MultiComparator(
compareByDynamic { it.durationSecs }, compareBy(NameComparator()) { it }))
}
override fun albumsInPlace(albums: MutableList<Album>) {
albums.sortWith(
MultiComparator(
compareByDynamic { it.durationSecs }, compareBy(NameComparator()) { it }))
}
override fun artistsInPlace(artists: MutableList<Artist>) {
artists.sortWith(
MultiComparator(
compareByDynamic { it.durationSecs }, compareBy(NameComparator()) { it }))
}
override fun genresInPlace(genres: MutableList<Genre>) {
genres.sortWith(
MultiComparator(
compareByDynamic { it.durationSecs }, compareBy(NameComparator()) { it }))
}
override fun ascending(newIsAscending: Boolean): Sort {
return ByDuration(newIsAscending)
}
}
/** Sort by the amount of songs. Only applicable to music parents. */
class ByCount(override val isAscending: Boolean) : Sort(isAscending) {
override val sortIntCode: Int
get() = IntegerTable.SORT_BY_COUNT
override val itemId: Int
get() = R.id.option_sort_count
override fun albumsInPlace(albums: MutableList<Album>) {
albums.sortWith(
MultiComparator(
compareByDynamic { it.songs.size }, compareBy(NameComparator()) { it }))
}
override fun artistsInPlace(artists: MutableList<Artist>) {
artists.sortWith(
MultiComparator(
compareByDynamic { it.songs.size }, compareBy(NameComparator()) { it }))
}
override fun genresInPlace(genres: MutableList<Genre>) {
genres.sortWith(
MultiComparator(
compareByDynamic { it.songs.size }, compareBy(NameComparator()) { it }))
}
override fun ascending(newIsAscending: Boolean): Sort {
return ByCount(newIsAscending)
}
}
/** /**
* Sort by the disc, and then track number of an item. Only supported by [Song]. Do not use this * Sort by the disc, and then track number of an item. Only supported by [Song]. Do not use this
* in a main sorting view, as it is not assigned to a particular item ID * in a main sorting view, as it is not assigned to a particular item ID
@ -267,6 +335,8 @@ sealed class Sort(open val isAscending: Boolean) {
R.id.option_sort_artist -> ByArtist(isAscending) R.id.option_sort_artist -> ByArtist(isAscending)
R.id.option_sort_album -> ByAlbum(isAscending) R.id.option_sort_album -> ByAlbum(isAscending)
R.id.option_sort_year -> ByYear(isAscending) R.id.option_sort_year -> ByYear(isAscending)
R.id.option_sort_duration -> ByDuration(isAscending)
R.id.option_sort_count -> ByCount(isAscending)
R.id.option_sort_disc -> ByDisc(isAscending) R.id.option_sort_disc -> ByDisc(isAscending)
R.id.option_sort_track -> ByTrack(isAscending) R.id.option_sort_track -> ByTrack(isAscending)
else -> null else -> null
@ -284,6 +354,16 @@ sealed class Sort(open val isAscending: Boolean) {
} }
} }
protected inline fun <T : Music, K : Comparable<K>> compareByDynamic(
crossinline selector: (T) -> K
): Comparator<T> {
return if (isAscending) {
compareBy(selector)
} else {
compareByDescending(selector)
}
}
class NameComparator<T : Music> : Comparator<T> { class NameComparator<T : Music> : Comparator<T> {
override fun compare(a: T, b: T): Int { override fun compare(a: T, b: T): Int {
val aSortName = a.sortName val aSortName = a.sortName
@ -341,15 +421,17 @@ sealed class Sort(open val isAscending: Boolean) {
* @return A [Sort] instance, null if the data is malformed. * @return A [Sort] instance, null if the data is malformed.
*/ */
fun fromIntCode(value: Int): Sort? { fun fromIntCode(value: Int): Sort? {
val ascending = (value and 1) == 1 val isAscending = (value and 1) == 1
return when (value.shr(1)) { return when (value.shr(1)) {
IntegerTable.SORT_BY_NAME -> ByName(ascending) IntegerTable.SORT_BY_NAME -> ByName(isAscending)
IntegerTable.SORT_BY_ARTIST -> ByArtist(ascending) IntegerTable.SORT_BY_ARTIST -> ByArtist(isAscending)
IntegerTable.SORT_BY_ALBUM -> ByAlbum(ascending) IntegerTable.SORT_BY_ALBUM -> ByAlbum(isAscending)
IntegerTable.SORT_BY_YEAR -> ByYear(ascending) IntegerTable.SORT_BY_YEAR -> ByYear(isAscending)
IntegerTable.SORT_BY_DISC -> ByDisc(ascending) IntegerTable.SORT_BY_DURATION -> ByDuration(isAscending)
IntegerTable.SORT_BY_TRACK -> ByTrack(ascending) IntegerTable.SORT_BY_COUNT -> ByCount(isAscending)
IntegerTable.SORT_BY_DISC -> ByDisc(isAscending)
IntegerTable.SORT_BY_TRACK -> ByTrack(isAscending)
else -> null else -> null
} }
} }

View file

@ -27,6 +27,12 @@
<item <item
android:id="@+id/option_sort_year" android:id="@+id/option_sort_year"
android:title="@string/lbl_sort_year" /> android:title="@string/lbl_sort_year" />
<item
android:id="@+id/option_sort_duration"
android:title="@string/lbl_sort_duration" />
<item
android:id="@+id/option_sort_count"
android:title="@string/lbl_sort_count" />
</group> </group>
<group android:checkableBehavior="all"> <group android:checkableBehavior="all">
<item <item

View file

@ -41,6 +41,8 @@
<dimen name="slider_thumb_radius">6dp</dimen> <dimen name="slider_thumb_radius">6dp</dimen>
<dimen name="slider_halo_radius">16dp</dimen> <dimen name="slider_halo_radius">16dp</dimen>
<dimen name="slider_thumb_radius_collapsed">6dp</dimen>
<dimen name="slider_thumb_radius_expanded">10dp</dimen>
<dimen name="recycler_fab_space_normal">88dp</dimen> <dimen name="recycler_fab_space_normal">88dp</dimen>
<dimen name="recycler_fab_space_large">128dp</dimen> <dimen name="recycler_fab_space_large">128dp</dimen>

View file

@ -24,6 +24,8 @@
<string name="lbl_sort_artist">Artist</string> <string name="lbl_sort_artist">Artist</string>
<string name="lbl_sort_album">Album</string> <string name="lbl_sort_album">Album</string>
<string name="lbl_sort_year">Year</string> <string name="lbl_sort_year">Year</string>
<string name="lbl_sort_duration">Duration</string>
<string name="lbl_sort_count">Song Count</string>
<string name="lbl_sort_disc">Disc</string> <string name="lbl_sort_disc">Disc</string>
<string name="lbl_sort_track">Track</string> <string name="lbl_sort_track">Track</string>
<string name="lbl_sort_asc">Ascending</string> <string name="lbl_sort_asc">Ascending</string>
@ -114,7 +116,7 @@
<string name="err_load_failed">Music loading failed</string> <string name="err_load_failed">Music loading failed</string>
<string name="err_no_perms">Auxio needs permission to read your music library</string> <string name="err_no_perms">Auxio needs permission to read your music library</string>
<string name="err_no_app">No app can open this link</string> <string name="err_no_app">No app can open this link</string>
<string name="err_bad_dir">This directory is not supported</string> <string name="err_bad_dir">This folder is not supported</string>
<string name="err_too_small">Auxio does not support this window size</string> <string name="err_too_small">Auxio does not support this window size</string>
<!-- Hint Namespace | EditText Hints --> <!-- Hint Namespace | EditText Hints -->