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:
parent
059652d2f1
commit
28feebcec3
28 changed files with 242 additions and 93 deletions
|
@ -92,10 +92,14 @@ object IntegerTable {
|
|||
const val SORT_BY_ALBUM = 0xA10E
|
||||
/** Sort.ByYear */
|
||||
const val SORT_BY_YEAR = 0xA10F
|
||||
/** Sort.ByDuration */
|
||||
const val SORT_BY_DURATION = 0xA114
|
||||
/** Sort.ByCount */
|
||||
const val SORT_BY_COUNT = 0xA115
|
||||
/** Sort.ByDisc */
|
||||
const val SORT_BY_DISC = 0xA114
|
||||
const val SORT_BY_DISC = 0xA116
|
||||
/** Sort.ByTrack */
|
||||
const val SORT_BY_TRACK = 0xA115
|
||||
const val SORT_BY_TRACK = 0xA117
|
||||
|
||||
/** ReplayGainMode.Off */
|
||||
const val REPLAY_GAIN_MODE_OFF = 0xA110
|
||||
|
|
|
@ -86,7 +86,7 @@ class MainActivity : AppCompatActivity() {
|
|||
if (action == Intent.ACTION_VIEW && !isConsumed) {
|
||||
// Mark the intent as used so this does not fire again
|
||||
intent.putExtra(KEY_INTENT_USED, true)
|
||||
intent.data?.let { fileUri -> playbackModel.playWithUri(fileUri, this) }
|
||||
intent.data?.let { fileUri -> playbackModel.play(fileUri, this) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,7 +89,7 @@ class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
|
|||
|
||||
override fun onItemClick(item: Item) {
|
||||
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() {
|
||||
playbackModel.playAlbum(unlikelyToBeNull(detailModel.currentAlbum.value), false)
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), false)
|
||||
}
|
||||
|
||||
override fun onShuffleParent() {
|
||||
playbackModel.playAlbum(unlikelyToBeNull(detailModel.currentAlbum.value), true)
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), true)
|
||||
}
|
||||
|
||||
override fun onShowSortMenu(anchor: View) {
|
||||
|
|
|
@ -74,7 +74,7 @@ class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
|
|||
|
||||
override fun onItemClick(item: Item) {
|
||||
when (item) {
|
||||
is Song -> playbackModel.playSong(item, PlaybackMode.IN_ARTIST)
|
||||
is Song -> playbackModel.play(item, PlaybackMode.IN_ARTIST)
|
||||
is Album -> navModel.exploreNavigateTo(item)
|
||||
}
|
||||
}
|
||||
|
@ -84,11 +84,11 @@ class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
|
|||
}
|
||||
|
||||
override fun onPlayParent() {
|
||||
playbackModel.playArtist(unlikelyToBeNull(detailModel.currentArtist.value), false)
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), false)
|
||||
}
|
||||
|
||||
override fun onShuffleParent() {
|
||||
playbackModel.playArtist(unlikelyToBeNull(detailModel.currentArtist.value), true)
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), true)
|
||||
}
|
||||
|
||||
override fun onShowSortMenu(anchor: View) {
|
||||
|
|
|
@ -71,7 +71,7 @@ class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
|
|||
|
||||
override fun onItemClick(item: Item) {
|
||||
when (item) {
|
||||
is Song -> playbackModel.playSong(item, PlaybackMode.IN_GENRE)
|
||||
is Song -> playbackModel.play(item, PlaybackMode.IN_GENRE)
|
||||
is Album ->
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
|
@ -83,11 +83,11 @@ class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
|
|||
}
|
||||
|
||||
override fun onPlayParent() {
|
||||
playbackModel.playGenre(unlikelyToBeNull(detailModel.currentGenre.value), false)
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), false)
|
||||
}
|
||||
|
||||
override fun onShuffleParent() {
|
||||
playbackModel.playGenre(unlikelyToBeNull(detailModel.currentGenre.value), true)
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), true)
|
||||
}
|
||||
|
||||
override fun onShowSortMenu(anchor: View) {
|
||||
|
|
|
@ -137,7 +137,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
|||
R.string.fmt_three,
|
||||
item.year?.toString() ?: context.getString(R.string.def_date),
|
||||
context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size),
|
||||
item.totalDuration)
|
||||
item.durationSecs.formatDuration(false))
|
||||
}
|
||||
|
||||
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.year == newItem.year &&
|
||||
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.songDuration.textSafe = item.seconds.formatDuration(false)
|
||||
binding.songDuration.textSafe = item.durationSecs.formatDuration(false)
|
||||
|
||||
binding.root.apply {
|
||||
setOnClickListener { listener.onItemClick(item) }
|
||||
|
@ -245,7 +245,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
|
|||
val DIFFER =
|
||||
object : SimpleItemCallback<Song>() {
|
||||
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName && oldItem.duration == newItem.duration
|
||||
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.ui.MenuItemListener
|
|||
import org.oxycblt.auxio.ui.SimpleItemCallback
|
||||
import org.oxycblt.auxio.ui.SongViewHolder
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.formatDuration
|
||||
import org.oxycblt.auxio.util.getPluralSafe
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
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.detailSubhead.textSafe =
|
||||
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.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
||||
}
|
||||
|
@ -137,7 +138,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
|
|||
override fun areItemsTheSame(oldItem: Genre, newItem: Genre) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.songs.size == newItem.songs.size &&
|
||||
oldItem.totalDuration == newItem.totalDuration
|
||||
oldItem.durationSecs == newItem.durationSecs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,8 +58,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
* @author OxygenCobalt
|
||||
*
|
||||
* TODO: Make tabs invisible when there is only one
|
||||
*
|
||||
* TODO: Add duration and song count sorts
|
||||
*/
|
||||
class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuItemClickListener {
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
@ -79,6 +77,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
|
||||
binding.homePager.apply {
|
||||
adapter = HomePagerAdapter()
|
||||
|
||||
// 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
|
||||
// page transitions.
|
||||
|
@ -138,9 +137,8 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
.getSortForDisplay(unlikelyToBeNull(homeModel.currentTab.value))
|
||||
.ascending(item.isChecked)))
|
||||
}
|
||||
|
||||
// Sorting option was selected, mark it as selected and update the mode
|
||||
else -> {
|
||||
// Sorting option was selected, mark it as selected and update the mode
|
||||
item.isChecked = true
|
||||
homeModel.updateCurrentSort(
|
||||
unlikelyToBeNull(
|
||||
|
@ -175,7 +173,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
val binding = requireBinding()
|
||||
when (tab) {
|
||||
DisplayMode.SHOW_SONGS -> {
|
||||
updateSortMenu(sortItem, tab)
|
||||
updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_count }
|
||||
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list
|
||||
}
|
||||
DisplayMode.SHOW_ALBUMS -> {
|
||||
|
@ -183,11 +181,21 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -196,7 +204,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
private fun updateSortMenu(
|
||||
item: MenuItem,
|
||||
displayMode: DisplayMode,
|
||||
isVisible: (Int) -> Boolean = { true }
|
||||
isVisible: (Int) -> Boolean
|
||||
) {
|
||||
val toHighlight = homeModel.getSortForDisplay(displayMode)
|
||||
|
||||
|
|
|
@ -91,6 +91,9 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback
|
|||
_shouldRecreateTabs.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the specific sort for the given [DisplayMode].
|
||||
*/
|
||||
fun getSortForDisplay(displayMode: DisplayMode): Sort {
|
||||
return when (displayMode) {
|
||||
DisplayMode.SHOW_SONGS -> settingsManager.libSongSort
|
||||
|
|
|
@ -30,6 +30,7 @@ import org.oxycblt.auxio.ui.MonoAdapter
|
|||
import org.oxycblt.auxio.ui.PrimitiveBackingData
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.newMenu
|
||||
import org.oxycblt.auxio.util.formatDuration
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -62,6 +63,12 @@ class AlbumListFragment : HomeListFragment<Album>() {
|
|||
// Year -> Use Full Year
|
||||
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
|
||||
else -> null
|
||||
}
|
||||
|
|
|
@ -23,11 +23,14 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.ui.ArtistViewHolder
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.Item
|
||||
import org.oxycblt.auxio.ui.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.MonoAdapter
|
||||
import org.oxycblt.auxio.ui.PrimitiveBackingData
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.newMenu
|
||||
import org.oxycblt.auxio.util.formatDuration
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -46,8 +49,24 @@ class ArtistListFragment : HomeListFragment<Artist>() {
|
|||
homeModel.artists.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
|
||||
}
|
||||
|
||||
override fun getPopup(pos: Int) =
|
||||
unlikelyToBeNull(homeModel.artists.value)[pos].sortName?.run { first().uppercase() }
|
||||
override fun getPopup(pos: Int): String? {
|
||||
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) {
|
||||
check(item is Music)
|
||||
|
|
|
@ -22,12 +22,15 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.GenreViewHolder
|
||||
import org.oxycblt.auxio.ui.Item
|
||||
import org.oxycblt.auxio.ui.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.MonoAdapter
|
||||
import org.oxycblt.auxio.ui.PrimitiveBackingData
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.newMenu
|
||||
import org.oxycblt.auxio.util.formatDuration
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -46,8 +49,24 @@ class GenreListFragment : HomeListFragment<Genre>() {
|
|||
homeModel.genres.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
|
||||
}
|
||||
|
||||
override fun getPopup(pos: Int) =
|
||||
unlikelyToBeNull(homeModel.genres.value)[pos].sortName?.run { first().uppercase() }
|
||||
override fun getPopup(pos: Int): String? {
|
||||
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) {
|
||||
check(item is Music)
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.ui.PrimitiveBackingData
|
|||
import org.oxycblt.auxio.ui.SongViewHolder
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.newMenu
|
||||
import org.oxycblt.auxio.util.formatDuration
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -66,15 +67,17 @@ class SongListFragment : HomeListFragment<Song>() {
|
|||
// Year -> Use Full Year
|
||||
is Sort.ByYear -> song.album.year?.toString()
|
||||
|
||||
// Unreachable state
|
||||
is Sort.ByDisc,
|
||||
is Sort.ByTrack -> null
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.ByDuration -> song.durationSecs.formatDuration(false)
|
||||
|
||||
// Unsupported sort, error gracefully
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
check(item is Song)
|
||||
playbackModel.playSong(item)
|
||||
playbackModel.play(item)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
|
|
|
@ -260,7 +260,7 @@ object Indexer {
|
|||
it._mediaStoreArtistName to
|
||||
it._mediaStoreAlbumArtistName to
|
||||
it.track to
|
||||
it.duration
|
||||
it.durationMs
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ import android.provider.MediaStore
|
|||
import androidx.core.text.isDigitsOnly
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.Item
|
||||
import org.oxycblt.auxio.util.formatDuration
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
// --- MUSIC MODELS ---
|
||||
|
@ -53,6 +52,10 @@ sealed class Music : Item() {
|
|||
sealed class MusicParent : Music() {
|
||||
/** The songs that this parent owns. */
|
||||
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. */
|
||||
|
@ -61,7 +64,7 @@ data class Song(
|
|||
/** The file name of this song, excluding the full path. */
|
||||
val fileName: String,
|
||||
/** 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. */
|
||||
val track: Int?,
|
||||
/** 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.artist.rawName.hashCode()
|
||||
result = 31 * result + (track ?: 0)
|
||||
result = 31 * result + duration.hashCode()
|
||||
result = 31 * result + durationMs.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
@ -99,8 +102,8 @@ data class Song(
|
|||
get() =
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, _mediaStoreId)
|
||||
/** The duration of this song, in seconds (rounded down) */
|
||||
val seconds: Long
|
||||
get() = duration / 1000
|
||||
val durationSecs: Long
|
||||
get() = durationMs / 1000
|
||||
|
||||
private var _album: Album? = null
|
||||
/** The album of this song. */
|
||||
|
@ -190,10 +193,6 @@ data class Album(
|
|||
|
||||
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
|
||||
/** The parent artist of this album. */
|
||||
val artist: Artist
|
||||
|
@ -256,10 +255,6 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
|
|||
|
||||
override fun resolveName(context: Context) =
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -34,6 +34,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
|
|||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.hardRestart
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
|
@ -144,7 +145,7 @@ class ExcludedDialog :
|
|||
return getRootPath() + "/" + typeAndPath.last()
|
||||
}
|
||||
|
||||
logD("Unsupported volume ${typeAndPath[0]}")
|
||||
logW("Unsupported volume ${typeAndPath[0]}")
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
@ -78,11 +78,11 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
binding.playbackSkipPrev?.setOnClickListener { playbackModel.skipPrev() }
|
||||
binding.playbackSkipPrev?.setOnClickListener { playbackModel.prev() }
|
||||
|
||||
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
|
||||
// we use colorSecondary instead of colorSurfaceVariant. This is because
|
||||
|
@ -107,7 +107,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
binding.playbackCover.bindAlbumCover(song)
|
||||
binding.playbackSong.textSafe = song.resolveName(context)
|
||||
binding.playbackInfo.textSafe = song.resolveIndividualArtistName(context)
|
||||
binding.playbackProgressBar.max = song.seconds.toInt()
|
||||
binding.playbackProgressBar.max = song.durationSecs.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ class PlaybackPanelFragment :
|
|||
}
|
||||
|
||||
binding.playbackRepeat.setOnClickListener { playbackModel.incrementRepeatMode() }
|
||||
binding.playbackSkipPrev.setOnClickListener { playbackModel.skipPrev() }
|
||||
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
||||
|
||||
binding.playbackPlayPause.apply {
|
||||
// Abuse the play/pause FAB (see style definition for more info)
|
||||
|
@ -116,7 +116,7 @@ class PlaybackPanelFragment :
|
|||
setOnClickListener { playbackModel.invertPlaying() }
|
||||
}
|
||||
|
||||
binding.playbackSkipNext.setOnClickListener { playbackModel.skipNext() }
|
||||
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
|
||||
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
|
||||
|
||||
binding.playbackSeekBar.apply {}
|
||||
|
@ -162,7 +162,7 @@ class PlaybackPanelFragment :
|
|||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
requireBinding().playbackPosition.isActivated = false
|
||||
playbackModel.setPosition(slider.value.toLong())
|
||||
playbackModel.seekTo(slider.value.toLong())
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
|
@ -182,7 +182,7 @@ class PlaybackPanelFragment :
|
|||
binding.playbackAlbum.textSafe = song.album.resolveName(context)
|
||||
|
||||
// Normally if a song had a duration
|
||||
val seconds = song.seconds
|
||||
val seconds = song.durationSecs
|
||||
binding.playbackDuration.textSafe = seconds.formatDuration(false)
|
||||
binding.playbackSeekBar.apply {
|
||||
isEnabled = seconds > 0L
|
||||
|
|
|
@ -99,7 +99,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
* Play a [song] with the [mode] specified. [mode] will default to the preferred song playback
|
||||
* 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)
|
||||
}
|
||||
|
||||
|
@ -107,10 +107,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
* Play an [album].
|
||||
* @param shuffled Whether to shuffle the new queue
|
||||
*/
|
||||
fun playAlbum(album: Album, shuffled: Boolean) {
|
||||
fun play(album: Album, shuffled: Boolean) {
|
||||
if (album.songs.isEmpty()) {
|
||||
logE("Album is empty, Not playing")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -121,10 +120,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
* Play an [artist].
|
||||
* @param shuffled Whether to shuffle the new queue
|
||||
*/
|
||||
fun playArtist(artist: Artist, shuffled: Boolean) {
|
||||
fun play(artist: Artist, shuffled: Boolean) {
|
||||
if (artist.songs.isEmpty()) {
|
||||
logE("Artist is empty, Not playing")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -135,10 +133,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
* Play a [genre].
|
||||
* @param shuffled Whether to shuffle the new queue
|
||||
*/
|
||||
fun playGenre(genre: Genre, shuffled: Boolean) {
|
||||
fun play(genre: Genre, shuffled: Boolean) {
|
||||
if (genre.songs.isEmpty()) {
|
||||
logE("Genre is empty, Not playing")
|
||||
|
||||
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.
|
||||
*/
|
||||
fun playWithUri(uri: Uri, context: Context) {
|
||||
fun play(uri: Uri, context: Context) {
|
||||
// Check if everything is already running to run the URI play
|
||||
if (playbackManager.isInitialized && musicStore.library != null) {
|
||||
playWithUriInternal(uri, context)
|
||||
playUriImpl(uri, context)
|
||||
} else {
|
||||
logD("Cant play this URI right now, waiting")
|
||||
|
||||
intentUri = uri
|
||||
}
|
||||
}
|
||||
|
||||
/** Play with a file URI. This is called after [playWithUri] once its deemed safe to do so. */
|
||||
private fun playWithUriInternal(uri: Uri, context: Context) {
|
||||
/** Play with a file URI. This is called after [play] once its deemed safe to do so. */
|
||||
private fun playUriImpl(uri: Uri, context: Context) {
|
||||
logD("Playing with uri $uri")
|
||||
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 */
|
||||
|
@ -174,19 +170,19 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
// --- POSITION FUNCTIONS ---
|
||||
|
||||
/** Update the position and push it to [PlaybackStateManager] */
|
||||
fun setPosition(progress: Long) {
|
||||
playbackManager.seekTo(progress * 1000)
|
||||
fun seekTo(positionSecs: Long) {
|
||||
playbackManager.seekTo(positionSecs * 1000)
|
||||
}
|
||||
|
||||
// --- QUEUE FUNCTIONS ---
|
||||
|
||||
/** Skip to the next song. */
|
||||
fun skipNext() {
|
||||
fun next() {
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
/** Skip to the previous song. */
|
||||
fun skipPrev() {
|
||||
fun prev() {
|
||||
playbackManager.prev()
|
||||
}
|
||||
|
||||
|
@ -271,14 +267,14 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun setupPlayback(context: Context) {
|
||||
val intentUri = intentUri
|
||||
|
||||
if (intentUri != null) {
|
||||
playWithUriInternal(intentUri, context)
|
||||
playUriImpl(intentUri, context)
|
||||
// Remove the uri after finishing the calls so that this does not fire again.
|
||||
this.intentUri = null
|
||||
} else if (!playbackManager.isInitialized) {
|
||||
|
|
|
@ -277,7 +277,7 @@ class PlaybackStateManager private constructor() {
|
|||
*/
|
||||
fun synchronizePosition(positionMs: Long) {
|
||||
// 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) {
|
||||
this.positionMs = positionMs
|
||||
notifyPositionChanged()
|
||||
|
|
|
@ -100,7 +100,7 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
|
|||
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genre.resolveName(context))
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, song.track?.toLong() ?: 0L)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_DATE, song.album.year?.toString())
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
|
||||
song.album.albumCoverUri.toString())
|
||||
|
|
|
@ -134,7 +134,7 @@ class SearchFragment :
|
|||
|
||||
override fun onItemClick(item: Item) {
|
||||
when (item) {
|
||||
is Song -> playbackModel.playSong(item)
|
||||
is Song -> playbackModel.play(item)
|
||||
is MusicParent -> navModel.exploreNavigateTo(item)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,7 +65,8 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
binding.aboutSongCount.textSafe = getString(R.string.fmt_songs_loaded, songs.size)
|
||||
binding.aboutTotalDuration.textSafe =
|
||||
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 ->
|
||||
|
|
|
@ -129,17 +129,17 @@ class ActionMenu(
|
|||
when (id) {
|
||||
R.id.action_play -> {
|
||||
when (data) {
|
||||
is Album -> playbackModel.playAlbum(data, false)
|
||||
is Artist -> playbackModel.playArtist(data, false)
|
||||
is Genre -> playbackModel.playGenre(data, false)
|
||||
is Album -> playbackModel.play(data, false)
|
||||
is Artist -> playbackModel.play(data, false)
|
||||
is Genre -> playbackModel.play(data, false)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
when (data) {
|
||||
is Album -> playbackModel.playAlbum(data, true)
|
||||
is Artist -> playbackModel.playArtist(data, true)
|
||||
is Genre -> playbackModel.playGenre(data, true)
|
||||
is Album -> playbackModel.play(data, true)
|
||||
is Artist -> playbackModel.play(data, true)
|
||||
is Genre -> playbackModel.play(data, true)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
* 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_album -> ByAlbum(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_track -> ByTrack(isAscending)
|
||||
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> {
|
||||
override fun compare(a: T, b: T): Int {
|
||||
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.
|
||||
*/
|
||||
fun fromIntCode(value: Int): Sort? {
|
||||
val ascending = (value and 1) == 1
|
||||
val isAscending = (value and 1) == 1
|
||||
|
||||
return when (value.shr(1)) {
|
||||
IntegerTable.SORT_BY_NAME -> ByName(ascending)
|
||||
IntegerTable.SORT_BY_ARTIST -> ByArtist(ascending)
|
||||
IntegerTable.SORT_BY_ALBUM -> ByAlbum(ascending)
|
||||
IntegerTable.SORT_BY_YEAR -> ByYear(ascending)
|
||||
IntegerTable.SORT_BY_DISC -> ByDisc(ascending)
|
||||
IntegerTable.SORT_BY_TRACK -> ByTrack(ascending)
|
||||
IntegerTable.SORT_BY_NAME -> ByName(isAscending)
|
||||
IntegerTable.SORT_BY_ARTIST -> ByArtist(isAscending)
|
||||
IntegerTable.SORT_BY_ALBUM -> ByAlbum(isAscending)
|
||||
IntegerTable.SORT_BY_YEAR -> ByYear(isAscending)
|
||||
IntegerTable.SORT_BY_DURATION -> ByDuration(isAscending)
|
||||
IntegerTable.SORT_BY_COUNT -> ByCount(isAscending)
|
||||
IntegerTable.SORT_BY_DISC -> ByDisc(isAscending)
|
||||
IntegerTable.SORT_BY_TRACK -> ByTrack(isAscending)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,12 @@
|
|||
<item
|
||||
android:id="@+id/option_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 android:checkableBehavior="all">
|
||||
<item
|
||||
|
|
|
@ -41,6 +41,8 @@
|
|||
|
||||
<dimen name="slider_thumb_radius">6dp</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_large">128dp</dimen>
|
||||
|
|
|
@ -24,6 +24,8 @@
|
|||
<string name="lbl_sort_artist">Artist</string>
|
||||
<string name="lbl_sort_album">Album</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_track">Track</string>
|
||||
<string name="lbl_sort_asc">Ascending</string>
|
||||
|
@ -114,7 +116,7 @@
|
|||
<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_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>
|
||||
|
||||
<!-- Hint Namespace | EditText Hints -->
|
||||
|
|
Loading…
Reference in a new issue