list: re-add fine-grained updates

Re-add the fine-grained updates that were removed prior due to state
concerns.

This time they should be safe enough to use, as the differ has been
fully vendored. The change is not complete enough however, as the queue
view has not been properly ported to use these updates just yet.
This commit is contained in:
Alexander Capehart 2023-03-04 23:08:03 -07:00
parent 6226b0a830
commit 86ca6d577e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
31 changed files with 585 additions and 539 deletions

View file

@ -33,7 +33,6 @@ import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -104,6 +103,9 @@ class AlbumDetailFragment :
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.albumInstructions.consume()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
@ -273,8 +275,8 @@ class AlbumDetailFragment :
} }
} }
private fun updateList(items: List<Item>) { private fun updateList(list: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF) detailAdapter.update(list, detailModel.albumInstructions.consume())
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {

View file

@ -33,7 +33,6 @@ import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -107,6 +106,9 @@ class ArtistDetailFragment :
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.artistInstructions.consume()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
@ -249,8 +251,8 @@ class ArtistDetailFragment :
} }
} }
private fun updateList(items: List<Item>) { private fun updateList(list: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF) detailAdapter.update(list, detailModel.artistInstructions.consume())
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.Disc
@ -80,6 +81,10 @@ constructor(
/** The current list data derived from [currentAlbum]. */ /** The current list data derived from [currentAlbum]. */
val albumList: StateFlow<List<Item>> val albumList: StateFlow<List<Item>>
get() = _albumList get() = _albumList
private val _albumInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumList] in the UI. */
val albumInstructions: Event<UpdateInstructions>
get() = _albumInstructions
/** The current [Sort] used for [Song]s in [albumList]. */ /** The current [Sort] used for [Song]s in [albumList]. */
var albumSongSort: Sort var albumSongSort: Sort
@ -87,7 +92,7 @@ constructor(
set(value) { set(value) {
musicSettings.albumSongSort = value musicSettings.albumSongSort = value
// Refresh the album list to reflect the new sort. // Refresh the album list to reflect the new sort.
currentAlbum.value?.let(::refreshAlbumList) currentAlbum.value?.let { refreshAlbumList(it, true) }
} }
// --- ARTIST --- // --- ARTIST ---
@ -100,6 +105,10 @@ constructor(
private val _artistList = MutableStateFlow(listOf<Item>()) private val _artistList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */ /** The current list derived from [currentArtist]. */
val artistList: StateFlow<List<Item>> = _artistList val artistList: StateFlow<List<Item>> = _artistList
private val _artistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistList] in the UI. */
val artistInstructions: Event<UpdateInstructions>
get() = _artistInstructions
/** The current [Sort] used for [Song]s in [artistList]. */ /** The current [Sort] used for [Song]s in [artistList]. */
var artistSongSort: Sort var artistSongSort: Sort
@ -107,7 +116,7 @@ constructor(
set(value) { set(value) {
musicSettings.artistSongSort = value musicSettings.artistSongSort = value
// Refresh the artist list to reflect the new sort. // Refresh the artist list to reflect the new sort.
currentArtist.value?.let(::refreshArtistList) currentArtist.value?.let { refreshArtistList(it, true) }
} }
// --- GENRE --- // --- GENRE ---
@ -120,6 +129,10 @@ constructor(
private val _genreList = MutableStateFlow(listOf<Item>()) private val _genreList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */ /** The current list data derived from [currentGenre]. */
val genreList: StateFlow<List<Item>> = _genreList val genreList: StateFlow<List<Item>> = _genreList
private val _genreInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistList] in the UI. */
val genreInstructions: Event<UpdateInstructions>
get() = _genreInstructions
/** The current [Sort] used for [Song]s in [genreList]. */ /** The current [Sort] used for [Song]s in [genreList]. */
var genreSongSort: Sort var genreSongSort: Sort
@ -127,7 +140,7 @@ constructor(
set(value) { set(value) {
musicSettings.genreSongSort = value musicSettings.genreSongSort = value
// Refresh the genre list to reflect the new sort. // Refresh the genre list to reflect the new sort.
currentGenre.value?.let(::refreshGenreList) currentGenre.value?.let { refreshGenreList(it, true) }
} }
/** /**
@ -242,11 +255,6 @@ constructor(
private fun <T : Music> requireMusic(uid: Music.UID) = musicRepository.library?.find<T>(uid) private fun <T : Music> requireMusic(uid: Music.UID) = musicRepository.library?.find<T>(uid)
/**
* Start a new job to load a given [Song]'s [AudioInfo]. Result is pushed to [songAudioInfo].
*
* @param song The song to load.
*/
private fun refreshAudioInfo(song: Song) { private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI. // Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel() currentSongJob?.cancel()
@ -259,10 +267,17 @@ constructor(
} }
} }
private fun refreshAlbumList(album: Album) { private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album data") logD("Refreshing album data")
val data = mutableListOf<Item>(album) val list = mutableListOf<Item>(album)
data.add(SortHeader(R.string.lbl_songs)) list.add(SortHeader(R.string.lbl_songs))
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced with the songs
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
// To create a good user experience regarding disc numbers, we group the album's // To create a good user experience regarding disc numbers, we group the album's
// songs up by disc and then delimit the groups by a disc header. // songs up by disc and then delimit the groups by a disc header.
@ -272,20 +287,21 @@ constructor(
if (byDisc.size > 1) { if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers") logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) { for (entry in byDisc.entries) {
data.add(entry.key) list.add(entry.key)
data.addAll(entry.value) list.addAll(entry.value)
} }
} else { } else {
// Album only has one disc, don't add any redundant headers // Album only has one disc, don't add any redundant headers
data.addAll(songs) list.addAll(songs)
} }
_albumList.value = data _albumInstructions.put(instructions)
_albumList.value = list
} }
private fun refreshArtistList(artist: Artist) { private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist data") logD("Refreshing artist data")
val data = mutableListOf<Item>(artist) val list = mutableListOf<Item>(artist)
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums) val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
val byReleaseGroup = val byReleaseGroup =
@ -312,29 +328,43 @@ constructor(
logD("Release groups for this artist: ${byReleaseGroup.keys}") logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) { for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
data.add(BasicHeader(entry.key.headerTitleRes)) list.add(BasicHeader(entry.key.headerTitleRes))
data.addAll(entry.value) list.addAll(entry.value)
} }
// Artists may not be linked to any songs, only include a header entry if we have any. // Artists may not be linked to any songs, only include a header entry if we have any.
var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) { if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header") logD("Songs present in this artist, adding header")
data.add(SortHeader(R.string.lbl_songs)) list.add(SortHeader(R.string.lbl_songs))
data.addAll(artistSongSort.songs(artist.songs)) if (replace) {
// Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size)
}
list.addAll(artistSongSort.songs(artist.songs))
} }
_artistList.value = data.toList() _artistInstructions.put(instructions)
_artistList.value = list.toList()
} }
private fun refreshGenreList(genre: Genre) { private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
logD("Refreshing genre data") logD("Refreshing genre data")
val data = mutableListOf<Item>(genre) val list = mutableListOf<Item>(genre)
// Genre is guaranteed to always have artists and songs. // Genre is guaranteed to always have artists and songs.
data.add(BasicHeader(R.string.lbl_artists)) list.add(BasicHeader(R.string.lbl_artists))
data.addAll(genre.artists) list.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs)) list.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSongSort.songs(genre.songs)) val instructions =
_genreList.value = data if (replace) {
// Intentional so that the header item isn't replaced with the songs
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
list.addAll(genreSongSort.songs(genre.songs))
_genreInstructions.put(instructions)
_genreList.value = list
} }
/** /**

View file

@ -33,7 +33,6 @@ import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -106,6 +105,9 @@ class GenreDetailFragment :
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.genreInstructions.consume()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
@ -232,8 +234,8 @@ class GenreDetailFragment :
} }
} }
private fun updateList(items: List<Item>) { private fun updateList(list: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF) detailAdapter.update(list, detailModel.genreInstructions.consume())
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {

View file

@ -30,7 +30,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.recycler.SongProperty import org.oxycblt.auxio.detail.recycler.SongProperty
import org.oxycblt.auxio.detail.recycler.SongPropertyAdapter import org.oxycblt.auxio.detail.recycler.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.AudioInfo
@ -79,7 +79,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
if (info != null) { if (info != null) {
val context = requireContext() val context = requireContext()
detailAdapter.submitList( detailAdapter.update(
buildList { buildList {
add(SongProperty(R.string.lbl_name, song.zipName(context))) add(SongProperty(R.string.lbl_name, song.zipName(context)))
add(SongProperty(R.string.lbl_album, song.album.zipName(context))) add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
@ -120,7 +120,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it))) R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
} }
}, },
BasicListInstructions.REPLACE) UpdateInstructions.Replace(0))
} }
} }

View file

@ -39,16 +39,14 @@ import org.oxycblt.auxio.util.inflater
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters. * A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
* *
* @param listener A [Listener] to bind interactions to. * @param listener A [Listener] to bind interactions to.
* @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the * @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* internal list.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class DetailAdapter( abstract class DetailAdapter(
private val listener: Listener<*>, private val listener: Listener<*>,
diffCallback: DiffUtil.ItemCallback<Item> private val diffCallback: DiffUtil.ItemCallback<Item>
) : ) :
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>( SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
ListDiffer.Async(diffCallback)),
AuxioRecyclerView.SpanSizeLookup { AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =

View file

@ -23,10 +23,7 @@ import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.*
import org.oxycblt.auxio.list.adapter.DiffAdapter
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
@ -37,8 +34,8 @@ import org.oxycblt.auxio.util.inflater
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SongPropertyAdapter : class SongPropertyAdapter :
DiffAdapter<SongProperty, BasicListInstructions, SongPropertyViewHolder>( FlexibleListAdapter<SongProperty, SongPropertyViewHolder>(
ListDiffer.Blocking(SongPropertyViewHolder.DIFF_CALLBACK)) { SongPropertyViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongPropertyViewHolder.from(parent) SongPropertyViewHolder.from(parent)

View file

@ -154,7 +154,7 @@ class HomeFragment :
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() } binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.shouldRecreate, ::handleRecreate) collect(homeModel.shouldRecreate.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexerState, ::updateIndexerState) collectImmediately(musicModel.indexerState, ::updateIndexerState)
@ -329,18 +329,14 @@ class HomeFragment :
} }
} }
private fun handleRecreate(recreate: Boolean) { private fun handleRecreate(recreate: Unit?) {
if (!recreate) { if (recreate == null) return
// Nothing to do
return
}
val binding = requireBinding() val binding = requireBinding()
// Move back to position zero, as there must be a tab there. // Move back to position zero, as there must be a tab there.
binding.homePager.currentItem = 0 binding.homePager.currentItem = 0
// Make sure tabs are set up to also follow the new ViewPager configuration. // Make sure tabs are set up to also follow the new ViewPager configuration.
setupPager(binding) setupPager(binding)
homeModel.finishRecreate() homeModel.shouldRecreate.consume()
} }
private fun updateIndexerState(state: Indexer.State?) { private fun updateIndexerState(state: Indexer.State?) {

View file

@ -24,9 +24,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
@ -48,11 +51,19 @@ constructor(
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songsList: StateFlow<List<Song>> val songsList: StateFlow<List<Song>>
get() = _songsList get() = _songsList
private val _songsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [songsList] in the UI. */
val songsInstructions: Event<UpdateInstructions>
get() = _songsInstructions
private val _albumsLists = MutableStateFlow(listOf<Album>()) private val _albumsLists = MutableStateFlow(listOf<Album>())
/** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */
val albumsList: StateFlow<List<Album>> val albumsList: StateFlow<List<Album>>
get() = _albumsLists get() = _albumsLists
private val _albumsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [albumsList] in the UI. */
val albumsInstructions: Event<UpdateInstructions>
get() = _albumsInstructions
private val _artistsList = MutableStateFlow(listOf<Artist>()) private val _artistsList = MutableStateFlow(listOf<Artist>())
/** /**
@ -62,11 +73,19 @@ constructor(
*/ */
val artistsList: MutableStateFlow<List<Artist>> val artistsList: MutableStateFlow<List<Artist>>
get() = _artistsList get() = _artistsList
private val _artistsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [artistsList] in the UI. */
val artistsInstructions: Event<UpdateInstructions>
get() = _artistsInstructions
private val _genresList = MutableStateFlow(listOf<Genre>()) private val _genresList = MutableStateFlow(listOf<Genre>())
/** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */
val genresList: StateFlow<List<Genre>> val genresList: StateFlow<List<Genre>>
get() = _genresList get() = _genresList
private val _genresInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genresList] in the UI. */
val genresInstructions: Event<UpdateInstructions>
get() = _genresInstructions
/** The [MusicMode] to use when playing a [Song] from the UI. */ /** The [MusicMode] to use when playing a [Song] from the UI. */
val playbackMode: MusicMode val playbackMode: MusicMode
@ -83,13 +102,14 @@ constructor(
/** The [MusicMode] of the currently shown [Tab]. */ /** The [MusicMode] of the currently shown [Tab]. */
val currentTabMode: StateFlow<MusicMode> = _currentTabMode val currentTabMode: StateFlow<MusicMode> = _currentTabMode
private val _shouldRecreate = MutableStateFlow(false) private val _shouldRecreate = MutableEvent<Unit>()
/** /**
* A marker to re-create all library tabs, usually initiated by a settings change. When this * A marker to re-create all library tabs, usually initiated by a settings change. When this
* flag is true, all tabs (and their respective ViewPager2 fragments) will be re-created from * flag is true, all tabs (and their respective ViewPager2 fragments) will be re-created from
* scratch. * scratch.
*/ */
val shouldRecreate: StateFlow<Boolean> = _shouldRecreate val shouldRecreate: Event<Unit>
get() = _shouldRecreate
private val _isFastScrolling = MutableStateFlow(false) private val _isFastScrolling = MutableStateFlow(false)
/** A marker for whether the user is fast-scrolling in the home view or not. */ /** A marker for whether the user is fast-scrolling in the home view or not. */
@ -111,8 +131,11 @@ constructor(
logD("Library changed, refreshing library") logD("Library changed, refreshing library")
// Get the each list of items in the library to use as our list data. // Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them. // Applying the preferred sorting to them.
_songsInstructions.put(UpdateInstructions.Diff)
_songsList.value = musicSettings.songSort.songs(library.songs) _songsList.value = musicSettings.songSort.songs(library.songs)
_albumsInstructions.put(UpdateInstructions.Diff)
_albumsLists.value = musicSettings.albumSort.albums(library.albums) _albumsLists.value = musicSettings.albumSort.albums(library.albums)
_artistsInstructions.put(UpdateInstructions.Diff)
_artistsList.value = _artistsList.value =
musicSettings.artistSort.artists( musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) { if (homeSettings.shouldHideCollaborators) {
@ -121,6 +144,7 @@ constructor(
} else { } else {
library.artists library.artists
}) })
_genresInstructions.put(UpdateInstructions.Diff)
_genresList.value = musicSettings.genreSort.genres(library.genres) _genresList.value = musicSettings.genreSort.genres(library.genres)
} }
} }
@ -128,7 +152,7 @@ constructor(
override fun onTabsChanged() { override fun onTabsChanged() {
// Tabs changed, update the current tabs and set up a re-create event. // Tabs changed, update the current tabs and set up a re-create event.
currentTabModes = makeTabModes() currentTabModes = makeTabModes()
_shouldRecreate.value = true _shouldRecreate.put(Unit)
} }
override fun onHideCollaboratorsChanged() { override fun onHideCollaboratorsChanged() {
@ -162,18 +186,22 @@ constructor(
when (_currentTabMode.value) { when (_currentTabMode.value) {
MusicMode.SONGS -> { MusicMode.SONGS -> {
musicSettings.songSort = sort musicSettings.songSort = sort
_songsInstructions.put(UpdateInstructions.Replace(0))
_songsList.value = sort.songs(_songsList.value) _songsList.value = sort.songs(_songsList.value)
} }
MusicMode.ALBUMS -> { MusicMode.ALBUMS -> {
musicSettings.albumSort = sort musicSettings.albumSort = sort
_albumsInstructions.put(UpdateInstructions.Replace(0))
_albumsLists.value = sort.albums(_albumsLists.value) _albumsLists.value = sort.albums(_albumsLists.value)
} }
MusicMode.ARTISTS -> { MusicMode.ARTISTS -> {
musicSettings.artistSort = sort musicSettings.artistSort = sort
_artistsInstructions.put(UpdateInstructions.Replace(0))
_artistsList.value = sort.artists(_artistsList.value) _artistsList.value = sort.artists(_artistsList.value)
} }
MusicMode.GENRES -> { MusicMode.GENRES -> {
musicSettings.genreSort = sort musicSettings.genreSort = sort
_genresInstructions.put(UpdateInstructions.Replace(0))
_genresList.value = sort.genres(_genresList.value) _genresList.value = sort.genres(_genresList.value)
} }
} }
@ -189,15 +217,6 @@ constructor(
_currentTabMode.value = currentTabModes[pagerPos] _currentTabMode.value = currentTabModes[pagerPos]
} }
/**
* Mark the recreation process as complete.
*
* @see shouldRecreate
*/
fun finishRecreate() {
_shouldRecreate.value = false
}
/** /**
* Update whether the user is fast scrolling or not in the home view. * Update whether the user is fast scrolling or not in the home view.
* *

View file

@ -32,8 +32,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -76,7 +74,7 @@ class AlbumListFragment :
listener = this@AlbumListFragment listener = this@AlbumListFragment
} }
collectImmediately(homeModel.albumsList, ::updateList) collectImmediately(homeModel.albumsList, ::updateAlbums)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
@ -140,8 +138,8 @@ class AlbumListFragment :
openMusicMenu(anchor, R.menu.menu_album_actions, item) openMusicMenu(anchor, R.menu.menu_album_actions, item)
} }
private fun updateList(albums: List<Album>) { private fun updateAlbums(albums: List<Album>) {
albumAdapter.submitList(albums, BasicListInstructions.REPLACE) albumAdapter.update(albums, homeModel.albumsInstructions.consume())
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -159,8 +157,7 @@ class AlbumListFragment :
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class AlbumAdapter(private val listener: SelectableListListener<Album>) : private class AlbumAdapter(private val listener: SelectableListListener<Album>) :
SelectionIndicatorAdapter<Album, BasicListInstructions, AlbumViewHolder>( SelectionIndicatorAdapter<Album, AlbumViewHolder>(AlbumViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(AlbumViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.from(parent) AlbumViewHolder.from(parent)

View file

@ -30,8 +30,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -43,6 +41,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
/** /**
@ -74,7 +73,7 @@ class ArtistListFragment :
listener = this@ArtistListFragment listener = this@ArtistListFragment
} }
collectImmediately(homeModel.artistsList, ::updateList) collectImmediately(homeModel.artistsList, ::updateArtists)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
@ -118,8 +117,8 @@ class ArtistListFragment :
openMusicMenu(anchor, R.menu.menu_artist_actions, item) openMusicMenu(anchor, R.menu.menu_artist_actions, item)
} }
private fun updateList(artists: List<Artist>) { private fun updateArtists(artists: List<Artist>) {
artistAdapter.submitList(artists, BasicListInstructions.REPLACE) artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) })
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -137,8 +136,7 @@ class ArtistListFragment :
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class ArtistAdapter(private val listener: SelectableListListener<Artist>) : private class ArtistAdapter(private val listener: SelectableListListener<Artist>) :
SelectionIndicatorAdapter<Artist, BasicListInstructions, ArtistViewHolder>( SelectionIndicatorAdapter<Artist, ArtistViewHolder>(ArtistViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(ArtistViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.from(parent) ArtistViewHolder.from(parent)

View file

@ -30,8 +30,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.GenreViewHolder import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -43,6 +41,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/** /**
* A [ListFragment] that shows a list of [Genre]s. * A [ListFragment] that shows a list of [Genre]s.
@ -73,7 +72,7 @@ class GenreListFragment :
listener = this@GenreListFragment listener = this@GenreListFragment
} }
collectImmediately(homeModel.genresList, ::updateList) collectImmediately(homeModel.genresList, ::updateGenres)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
@ -117,8 +116,8 @@ class GenreListFragment :
openMusicMenu(anchor, R.menu.menu_artist_actions, item) openMusicMenu(anchor, R.menu.menu_artist_actions, item)
} }
private fun updateList(artists: List<Genre>) { private fun updateGenres(genres: List<Genre>) {
genreAdapter.submitList(artists, BasicListInstructions.REPLACE) genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) })
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -136,8 +135,7 @@ class GenreListFragment :
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class GenreAdapter(private val listener: SelectableListListener<Genre>) : private class GenreAdapter(private val listener: SelectableListListener<Genre>) :
SelectionIndicatorAdapter<Genre, BasicListInstructions, GenreViewHolder>( SelectionIndicatorAdapter<Genre, GenreViewHolder>(GenreViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(GenreViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.from(parent) GenreViewHolder.from(parent)

View file

@ -32,8 +32,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -79,7 +77,7 @@ class SongListFragment :
listener = this@SongListFragment listener = this@SongListFragment
} }
collectImmediately(homeModel.songsList, ::updateList) collectImmediately(homeModel.songsList, ::updateSongs)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -147,8 +145,8 @@ class SongListFragment :
openMusicMenu(anchor, R.menu.menu_song_actions, item) openMusicMenu(anchor, R.menu.menu_song_actions, item)
} }
private fun updateList(songs: List<Song>) { private fun updateSongs(songs: List<Song>) {
songAdapter.submitList(songs, BasicListInstructions.REPLACE) songAdapter.update(songs, homeModel.songsInstructions.consume())
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -170,8 +168,7 @@ class SongListFragment :
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class SongAdapter(private val listener: SelectableListListener<Song>) : private class SongAdapter(private val listener: SelectableListListener<Song>) :
SelectionIndicatorAdapter<Song, BasicListInstructions, SongViewHolder>( SelectionIndicatorAdapter<Song, SongViewHolder>(SongViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(SongViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.from(parent) SongViewHolder.from(parent)

View file

@ -37,6 +37,11 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
/**
* Stateless interface for loading [Album] cover image data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface CoverExtractor { interface CoverExtractor {
/** /**
* Fetch an album cover, respecting the current cover configuration. * Fetch an album cover, respecting the current cover configuration.

View file

@ -1,56 +0,0 @@
/*
* Copyright (c) 2023 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.list.adapter
import androidx.recyclerview.widget.RecyclerView
/**
* A [RecyclerView.Adapter] with [ListDiffer] integration.
*
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use.
*/
abstract class DiffAdapter<T, I, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I>
) : RecyclerView.Adapter<VH>() {
private val differ = differFactory.new(@Suppress("LeakingThis") this)
final override fun getItemCount() = differ.currentList.size
/** The current list of [T] items. */
val currentList: List<T>
get() = differ.currentList
/**
* Get a [T] item at the given position.
*
* @param at The position to get the item at.
* @throws IndexOutOfBoundsException If the index is not in the list bounds/
*/
fun getItem(at: Int) = differ.currentList[at]
/**
* Dynamically determine how to update the list based on the given instructions.
*
* @param newList The new list of [T] items to show.
* @param instructions The instructions specifying how to update the list.
* @param onDone Called when the update process is completed. Defaults to a no-op.
*/
fun submitList(newList: List<T>, instructions: I, onDone: () -> Unit = {}) {
differ.submitList(newList, instructions, onDone)
}
}

View file

@ -0,0 +1,237 @@
/*
* Copyright (c) 2023 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.list.adapter
import android.os.Handler
import android.os.Looper
import androidx.recyclerview.widget.*
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.Executor
/**
* A variant of ListDiffer with more flexible updates.
*
* @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
diffCallback: DiffUtil.ItemCallback<T>
) : RecyclerView.Adapter<VH>() {
private val differ = FlexibleListDiffer(this, diffCallback)
final override fun getItemCount() = differ.currentList.size
/** The current list stored by the adapter's differ instance. */
val currentList: List<T>
get() = differ.currentList
/** @see currentList */
fun getItem(at: Int) = differ.currentList[at]
/**
* Update the adapter with new data.
*
* @param newData The new list of data to update with.
* @param instructions The [UpdateInstructions] to visually update the list with.
* @param callback Called when the update is completed. May be done asynchronously.
*/
fun update(
newData: List<T>,
instructions: UpdateInstructions?,
callback: (() -> Unit)? = null
) = differ.update(newData, instructions, callback)
}
/**
* Arbitrary instructions that can be given to a [FlexibleListAdapter] to direct how it updates
* data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class UpdateInstructions {
/** Use an asynchronous diff. Useful for unpredictable updates, but looks chaotic and janky. */
object Diff : UpdateInstructions()
/**
* Visually replace all items from a given point. More visually coherent than [Diff].
*
* @param from The index at which to start replacing items (inclusive)
*/
data class Replace(val from: Int) : UpdateInstructions()
/**
* Move one item to another location.
*
* @param from The index of the item to move.
* @param to The index to move the item to.
*/
data class Move(val from: Int, val to: Int) : UpdateInstructions()
/**
* Remove an item.
*
* @param at The location that the item should be removed from.
*/
data class Remove(val at: Int) : UpdateInstructions()
}
/**
* Vendor of AsyncListDiffer with more flexible update functionality.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class FlexibleListDiffer<T>(
adapter: RecyclerView.Adapter<*>,
diffCallback: DiffUtil.ItemCallback<T>
) {
private val updateCallback = AdapterListUpdateCallback(adapter)
private val config = AsyncDifferConfig.Builder(diffCallback).build()
private val mainThreadExecutor = sMainThreadExecutor
private class MainThreadExecutor : Executor {
val mHandler = Handler(Looper.getMainLooper())
override fun execute(command: Runnable) {
mHandler.post(command)
}
}
var currentList = emptyList<T>()
private set
private var maxScheduledGeneration = 0
fun update(newList: List<T>, instructions: UpdateInstructions?, callback: (() -> Unit)?) {
// incrementing generation means any currently-running diffs are discarded when they finish
val runGeneration = ++maxScheduledGeneration
if (currentList == newList) {
callback?.invoke()
return
}
when (instructions) {
is UpdateInstructions.Replace -> {
updateCallback.onRemoved(instructions.from, currentList.size - instructions.from)
currentList = newList
if (newList.lastIndex >= instructions.from) {
// Need to re-insert the new data.
updateCallback.onInserted(instructions.from, newList.size - instructions.from)
}
callback?.invoke()
}
is UpdateInstructions.Move -> {
currentList = newList
updateCallback.onMoved(instructions.from, instructions.to)
callback?.invoke()
}
is UpdateInstructions.Remove -> {
currentList = newList
updateCallback.onRemoved(instructions.at, 1)
callback?.invoke()
}
is UpdateInstructions.Diff,
null -> diffList(currentList, newList, runGeneration, callback)
}
}
private fun diffList(
oldList: List<T>,
newList: List<T>,
runGeneration: Int,
callback: (() -> Unit)?
) {
// fast simple remove all
if (newList.isEmpty()) {
val countRemoved = oldList.size
currentList = emptyList()
// notify last, after list is updated
updateCallback.onRemoved(0, countRemoved)
callback?.invoke()
return
}
// fast simple first insert
if (oldList.isEmpty()) {
currentList = newList
// notify last, after list is updated
updateCallback.onInserted(0, newList.size)
callback?.invoke()
return
}
config.backgroundThreadExecutor.execute {
val result =
DiffUtil.calculateDiff(
object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
config.diffCallback.areItemsTheSame(oldItem, newItem)
} else oldItem == null && newItem == null
// If both items are null we consider them the same.
}
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
if (oldItem != null && newItem != null) {
return config.diffCallback.areContentsTheSame(oldItem, newItem)
}
if (oldItem == null && newItem == null) {
return true
}
throw AssertionError()
}
override fun getChangePayload(
oldItemPosition: Int,
newItemPosition: Int
): Any? {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
if (oldItem != null && newItem != null) {
return config.diffCallback.getChangePayload(oldItem, newItem)
}
throw AssertionError()
}
})
mainThreadExecutor.execute {
if (maxScheduledGeneration == runGeneration) {
currentList = newList
result.dispatchUpdatesTo(updateCallback)
callback?.invoke()
}
}
}
}
companion object {
private val sMainThreadExecutor: Executor = MainThreadExecutor()
}
}

View file

@ -1,232 +0,0 @@
/*
* Copyright (c) 2023 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.list.adapter
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
// TODO: Re-add list instructions with a less dangerous framework.
/**
* List differ wrapper that provides more flexibility regarding the way lists are updated.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface ListDiffer<T, I> {
/** The current list of [T] items. */
val currentList: List<T>
/**
* Dynamically determine how to update the list based on the given instructions.
*
* @param newList The new list of [T] items to show.
* @param instructions The [BasicListInstructions] specifying how to update the list.
* @param onDone Called when the update process is completed.
*/
fun submitList(newList: List<T>, instructions: I, onDone: () -> Unit)
/**
* Defines the creation of new [ListDiffer] instances. Allows such [ListDiffer]s to be passed as
* arguments without reliance on a `this` [RecyclerView.Adapter].
*/
abstract class Factory<T, I> {
/**
* Create a new [ListDiffer] bound to the given [RecyclerView.Adapter].
*
* @param adapter The [RecyclerView.Adapter] to bind to.
*/
abstract fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, I>
}
/**
* Update lists on another thread. This is useful when large diffs are likely to occur in this
* list that would be exceedingly slow with [Blocking].
*
* @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the
* internal list.
*/
class Async<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
AsyncListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
/**
* Update lists on the main thread. This is useful when many small, discrete list diffs are
* likely to occur that would cause [Async] to suffer from race conditions.
*
* @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the
* internal list.
*/
class Blocking<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
BlockingListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
}
/**
* Represents the specific way to update a list of items.
*
* @author Alexander Capehart (OxygenCobalt)
*/
enum class BasicListInstructions {
/**
* (A)synchronously diff the list. This should be used for small diffs with little item
* movement.
*/
DIFF,
/**
* Synchronously remove the current list and replace it with a new one. This should be used for
* large diffs with that would cause erratic scroll behavior or in-efficiency.
*/
REPLACE
}
private abstract class BasicListDiffer<T> : ListDiffer<T, BasicListInstructions> {
override fun submitList(
newList: List<T>,
instructions: BasicListInstructions,
onDone: () -> Unit
) {
when (instructions) {
BasicListInstructions.DIFF -> diffList(newList, onDone)
BasicListInstructions.REPLACE -> replaceList(newList, onDone)
}
}
protected abstract fun diffList(newList: List<T>, onDone: () -> Unit)
protected abstract fun replaceList(newList: List<T>, onDone: () -> Unit)
}
private class AsyncListDifferImpl<T>(
updateCallback: ListUpdateCallback,
diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<T>() {
private val inner =
AsyncListDiffer(updateCallback, AsyncDifferConfig.Builder(diffCallback).build())
override val currentList: List<T>
get() = inner.currentList
override fun diffList(newList: List<T>, onDone: () -> Unit) {
inner.submitList(newList, onDone)
}
override fun replaceList(newList: List<T>, onDone: () -> Unit) {
inner.submitList(null) { inner.submitList(newList, onDone) }
}
}
private class BlockingListDifferImpl<T>(
private val updateCallback: ListUpdateCallback,
private val diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<T>() {
override var currentList = listOf<T>()
override fun diffList(newList: List<T>, onDone: () -> Unit) {
if (newList === currentList || newList.isEmpty() && currentList.isEmpty()) {
onDone()
return
}
if (newList.isEmpty()) {
val oldListSize = currentList.size
currentList = listOf()
updateCallback.onRemoved(0, oldListSize)
onDone()
return
}
if (currentList.isEmpty()) {
currentList = newList
updateCallback.onInserted(0, newList.size)
onDone()
return
}
val oldList = currentList
val result =
DiffUtil.calculateDiff(
object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
diffCallback.areItemsTheSame(oldItem, newItem)
} else {
oldItem == null && newItem == null
}
}
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
diffCallback.areContentsTheSame(oldItem, newItem)
} else if (oldItem == null && newItem == null) {
true
} else {
throw AssertionError()
}
}
override fun getChangePayload(
oldItemPosition: Int,
newItemPosition: Int
): Any? {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
diffCallback.getChangePayload(oldItem, newItem)
} else {
throw AssertionError()
}
}
})
currentList = newList
result.dispatchUpdatesTo(updateCallback)
onDone()
}
override fun replaceList(newList: List<T>, onDone: () -> Unit) {
if (currentList != newList) {
diffList(listOf()) { diffList(newList, onDone) }
}
}
}

View file

@ -18,18 +18,19 @@
package org.oxycblt.auxio.list.adapter package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item. * A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
* *
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. * @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>( abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I> diffCallback: DiffUtil.ItemCallback<T>
) : DiffAdapter<T, I, VH>(differFactory) { ) : FlexibleListAdapter<T, VH>(diffCallback) {
// There are actually two states for this adapter: // There are actually two states for this adapter:
// - The currently playing item, which is usually marked as "selected" and becomes accented. // - The currently playing item, which is usually marked as "selected" and becomes accented.
// - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is // - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is
@ -40,7 +41,7 @@ abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
// Only try to update the playing indicator if the ViewHolder supports it // Only try to update the playing indicator if the ViewHolder supports it
if (holder is ViewHolder) { if (holder is ViewHolder) {
holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying) holder.updatePlayingIndicator(getItem(position) == currentItem, isPlaying)
} }
if (payloads.isEmpty()) { if (payloads.isEmpty()) {

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.list.adapter package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
@ -25,12 +26,12 @@ import org.oxycblt.auxio.music.Music
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
* items. * items.
* *
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. * @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>( abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I> diffCallback: DiffUtil.ItemCallback<T>
) : PlayingIndicatorAdapter<T, I, VH>(differFactory) { ) : PlayingIndicatorAdapter<T, VH>(diffCallback) {
private var selectedItems = setOf<T>() private var selectedItems = setOf<T>()
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
@ -64,9 +65,7 @@ abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
} }
// Only update items that were added or removed from the list. // Only update items that were added or removed from the list.
val added = !oldSelectedItems.contains(item) && newSelectedItems.contains(item) if (oldSelectedItems.contains(item) xor newSelectedItems.contains(item)) {
val removed = oldSelectedItems.contains(item) && !newSelectedItems.contains(item)
if (added || removed) {
notifyItemChanged(i, PAYLOAD_SELECTION_INDICATOR_CHANGED) notifyItemChanged(i, PAYLOAD_SELECTION_INDICATOR_CHANGED)
} }
} }

View file

@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.BackportMaterialDividerItemDecoration import com.google.android.material.divider.BackportMaterialDividerItemDecoration
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.adapter.DiffAdapter import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
/** /**
* A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly * A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly
@ -45,7 +45,7 @@ constructor(
// Add a divider if the next item is a header. This organizes the divider to separate // Add a divider if the next item is a header. This organizes the divider to separate
// the ends of content rather than the beginning of content, alongside an added benefit // the ends of content rather than the beginning of content, alongside an added benefit
// of preventing top headers from having a divider applied. // of preventing top headers from having a divider applied.
(adapter as DiffAdapter<*, *, *>).getItem(position + 1) is Header (adapter as FlexibleListAdapter<*, *>).getItem(position + 1) is Header
} catch (e: ClassCastException) { } catch (e: ClassCastException) {
false false
} catch (e: IndexOutOfBoundsException) { } catch (e: IndexOutOfBoundsException) {

View file

@ -29,20 +29,35 @@ import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
/**
* An processing abstraction over the [MetadataRetriever] and [TextTags] workflow that operates on
* [RawSong] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface TagWorker { interface TagWorker {
/**
* Poll to see if this worker is done processing.
*
* @return A completed [RawSong] if done, null otherwise.
*/
fun poll(): RawSong? fun poll(): RawSong?
/** Factory for new [TagWorker] jobs. */
interface Factory { interface Factory {
/**
* Create a new [TagWorker] to complete the given [RawSong].
*
* @param rawSong The [RawSong] to assign a new [TagWorker] to.
* @return A new [TagWorker] wrapping the given [RawSong].
*/
fun create(rawSong: RawSong): TagWorker fun create(rawSong: RawSong): TagWorker
} }
} }
class TagWorkerImpl(private val rawSong: RawSong, private val future: Future<TrackGroupArray>) : class TagWorkerImpl
private constructor(private val rawSong: RawSong, private val future: Future<TrackGroupArray>) :
TagWorker { TagWorker {
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
/** /**
* Try to get a completed song from this [TagWorker], if it has finished processing. * Try to get a completed song from this [TagWorker], if it has finished processing.
* *
@ -246,6 +261,9 @@ class TagWorkerImpl(private val rawSong: RawSong, private val future: Future<Tra
class Factory @Inject constructor(private val mediaSourceFactory: MediaSource.Factory) : class Factory @Inject constructor(private val mediaSourceFactory: MediaSource.Factory) :
TagWorker.Factory { TagWorker.Factory {
override fun create(rawSong: RawSong) = override fun create(rawSong: RawSong) =
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
TagWorkerImpl( TagWorkerImpl(
rawSong, rawSong,
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(

View file

@ -98,6 +98,26 @@ interface Queue {
} }
} }
data class QueueChange(val internal: InternalChange, val externalChange: ExternalChange) {
enum class InternalChange {
/** Only the mapping has changed. */
MAPPING,
/** The mapping has changed, and the index also changed to align with it. */
INDEX,
/**
* The current song has changed, possibly alongside the mapping and index depending on the
* context.
*/
SONG
}
sealed class ExternalChange {
data class Add(val at: Int, val amount: Int) : ExternalChange()
data class Remove(val at: Int) : ExternalChange()
data class Move(val from: Int, val to: Int) : ExternalChange()
}
}
class EditableQueue : Queue { class EditableQueue : Queue {
@Volatile private var heap = mutableListOf<Song>() @Volatile private var heap = mutableListOf<Song>()
@Volatile private var orderedMapping = mutableListOf<Int>() @Volatile private var orderedMapping = mutableListOf<Int>()

View file

@ -27,10 +27,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemQueueSongBinding import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.list.EditableListListener import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.*
import org.oxycblt.auxio.list.adapter.DiffAdapter
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
@ -43,8 +40,7 @@ import org.oxycblt.auxio.util.*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueAdapter(private val listener: EditableListListener<Song>) : class QueueAdapter(private val listener: EditableListListener<Song>) :
DiffAdapter<Song, BasicListInstructions, QueueSongViewHolder>( FlexibleListAdapter<Song, QueueSongViewHolder>(QueueSongViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(QueueSongViewHolder.DIFF_CALLBACK)) {
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this // Since PlayingIndicator adapter relies on an item value, we cannot use it for this
// adapter, as one item can appear at several points in the UI. Use a similar implementation // adapter, as one item can appear at several points in the UI. Use a similar implementation
// with an index value instead. // with an index value instead.

View file

@ -28,7 +28,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.list.EditableListListener import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
@ -102,13 +101,12 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
val binding = requireBinding() val binding = requireBinding()
// Replace or diff the queue depending on the type of change it is. // Replace or diff the queue depending on the type of change it is.
val instructions = queueModel.queueListInstructions queueAdapter.update(queue, queueModel.queueInstructions.consume())
queueAdapter.submitList(queue, instructions?.update ?: BasicListInstructions.DIFF)
// Update position in list (and thus past/future items) // Update position in list (and thus past/future items)
queueAdapter.setPosition(index, isPlaying) queueAdapter.setPosition(index, isPlaying)
// If requested, scroll to a new item (occurs when the index moves) // If requested, scroll to a new item (occurs when the index moves)
val scrollTo = instructions?.scrollTo val scrollTo = queueModel.scrollTo.consume()
if (scrollTo != null) { if (scrollTo != null) {
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition() val start = lmm.findFirstCompletelyVisibleItemPosition()
@ -129,7 +127,5 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
min(queue.lastIndex, scrollTo + (end - start))) min(queue.lastIndex, scrollTo + (end - start)))
} }
} }
queueModel.finishInstructions()
} }
} }

View file

@ -22,10 +22,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
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.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
/** /**
* A [ViewModel] that manages the current queue state and allows navigation through the queue. * A [ViewModel] that manages the current queue state and allows navigation through the queue.
@ -39,27 +41,32 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
private val _queue = MutableStateFlow(listOf<Song>()) private val _queue = MutableStateFlow(listOf<Song>())
/** The current queue. */ /** The current queue. */
val queue: StateFlow<List<Song>> = _queue val queue: StateFlow<List<Song>> = _queue
private val _queueInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [queue] in the UI. */
val queueInstructions: Event<UpdateInstructions> = _queueInstructions
private val _scrollTo = MutableEvent<Int>()
/** Controls whether the queue should be force-scrolled to a particular location. */
val scrollTo: Event<Int>
get() = _scrollTo
private val _index = MutableStateFlow(playbackManager.queue.index) private val _index = MutableStateFlow(playbackManager.queue.index)
/** The index of the currently playing song in the queue. */ /** The index of the currently playing song in the queue. */
val index: StateFlow<Int> val index: StateFlow<Int>
get() = _index get() = _index
/** Specifies how to update the list when the queue changes. */
var queueListInstructions: ListInstructions? = null
init { init {
playbackManager.addListener(this) playbackManager.addListener(this)
} }
override fun onIndexMoved(queue: Queue) { override fun onIndexMoved(queue: Queue) {
queueListInstructions = ListInstructions(null, queue.index) _scrollTo.put(queue.index)
_index.value = queue.index _index.value = queue.index
} }
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
// Queue changed trivially due to item mo -> Diff queue, stay at current index. // Queue changed trivially due to item mo -> Diff queue, stay at current index.
queueListInstructions = ListInstructions(BasicListInstructions.DIFF, null) // TODO: Terrible idea, need to manually deliver updates
_queueInstructions.put(UpdateInstructions.Diff)
_queue.value = queue.resolve() _queue.value = queue.resolve()
if (change != Queue.ChangeResult.MAPPING) { if (change != Queue.ChangeResult.MAPPING) {
// Index changed, make sure it remains updated without actually scrolling to it. // Index changed, make sure it remains updated without actually scrolling to it.
@ -69,14 +76,16 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
override fun onQueueReordered(queue: Queue) { override fun onQueueReordered(queue: Queue) {
// Queue changed completely -> Replace queue, update index // Queue changed completely -> Replace queue, update index
queueListInstructions = ListInstructions(BasicListInstructions.REPLACE, queue.index) _queueInstructions.put(UpdateInstructions.Replace(0))
_scrollTo.put(queue.index)
_queue.value = queue.resolve() _queue.value = queue.resolve()
_index.value = queue.index _index.value = queue.index
} }
override fun onNewPlayback(queue: Queue, parent: MusicParent?) { override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index // Entirely new queue -> Replace queue, update index
queueListInstructions = ListInstructions(BasicListInstructions.REPLACE, queue.index) _queueInstructions.put(UpdateInstructions.Replace(0))
_scrollTo.put(queue.index)
_queue.value = queue.resolve() _queue.value = queue.resolve()
_index.value = queue.index _index.value = queue.index
} }
@ -123,11 +132,4 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
playbackManager.moveQueueItem(adapterFrom, adapterTo) playbackManager.moveQueueItem(adapterFrom, adapterTo)
return true return true
} }
/** Signal that the specified [ListInstructions] in [queueListInstructions] were performed. */
fun finishInstructions() {
queueListInstructions = null
}
class ListInstructions(val update: BasicListInstructions?, val scrollTo: Int?)
} }

View file

@ -114,9 +114,6 @@ class PlaybackService :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Define our own extractors so we can exclude non-audio parsers.
// Ordering is derived from the DefaultExtractorsFactory's optimized ordering:
// https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ.
// Since Auxio is a music player, only specify an audio renderer to save // Since Auxio is a music player, only specify an audio renderer to save
// battery/apk size/cache size // battery/apk size/cache size
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->

View file

@ -20,8 +20,6 @@ package org.oxycblt.auxio.search
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.* import org.oxycblt.auxio.list.recycler.*
@ -35,8 +33,7 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SearchAdapter(private val listener: SelectableListListener<Music>) : class SearchAdapter(private val listener: SelectableListListener<Music>) :
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>( SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(DIFF_CALLBACK),
ListDiffer.Async(DIFF_CALLBACK)),
AuxioRecyclerView.SpanSizeLookup { AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =

View file

@ -34,7 +34,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSearchBinding import org.oxycblt.auxio.databinding.FragmentSearchBinding
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -163,7 +162,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
// Don't show the RecyclerView (and it's stray overscroll effects) when there // Don't show the RecyclerView (and it's stray overscroll effects) when there
// are no results. // are no results.
binding.searchRecycler.isInvisible = results.isEmpty() binding.searchRecycler.isInvisible = results.isEmpty()
searchAdapter.submitList(results.toMutableList(), BasicListInstructions.DIFF) { searchAdapter.update(results.toMutableList(), null) {
// I would make it so that the position is only scrolled back to the top when // I would make it so that the position is only scrolled back to the top when
// the query actually changes instead of once every re-creation event, but sadly // the query actually changes instead of once every re-creation event, but sadly
// that doesn't seem possible. // that doesn't seem possible.

View file

@ -28,16 +28,8 @@ import androidx.appcompat.widget.AppCompatButton
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/** /**
* Get if this [View] contains the given [PointF], with optional leeway. * Get if this [View] contains the given [PointF], with optional leeway.
@ -127,88 +119,6 @@ fun AppCompatButton.fixDoubleRipple() {
val View.coordinatorLayoutBehavior: CoordinatorLayout.Behavior<View>? val View.coordinatorLayoutBehavior: CoordinatorLayout.Behavior<View>?
get() = (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior get() = (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior
/**
* Collect a [StateFlow] into [block] in a lifecycle-aware manner *eventually.* Due to co-routine
* launching, the initializing call will occur ~100ms after draw time. If this is not desirable, use
* [collectImmediately].
*
* @param stateFlow The [StateFlow] to collect.
* @param block The code to run when the [StateFlow] updates.
*/
fun <T> Fragment.collect(stateFlow: StateFlow<T>, block: (T) -> Unit) {
launch { stateFlow.collect(block) }
}
/**
* Collect a [StateFlow] into a [block] in a lifecycle-aware manner *immediately.* This will
* immediately run an initializing call to ensure the UI is set up before draw-time. Note that this
* will result in two initializing calls.
*
* @param stateFlow The [StateFlow] to collect.
* @param block The code to run when the [StateFlow] updates.
*/
fun <T> Fragment.collectImmediately(stateFlow: StateFlow<T>, block: (T) -> Unit) {
block(stateFlow.value)
launch { stateFlow.collect(block) }
}
/**
* Like [collectImmediately], but with two [StateFlow] instances that are collected with the same
* block.
*
* @param a The first [StateFlow] to collect.
* @param b The second [StateFlow] to collect.
* @param block The code to run when either [StateFlow] updates.
*/
fun <T1, T2> Fragment.collectImmediately(
a: StateFlow<T1>,
b: StateFlow<T2>,
block: (T1, T2) -> Unit
) {
block(a.value, b.value)
// We can combine flows, but only if we transform them into one flow output.
// Thus, we have to first combine the two flow values into a Pair, and then
// decompose it when we collect the values.
val combine = a.combine(b) { first, second -> Pair(first, second) }
launch { combine.collect { block(it.first, it.second) } }
}
/**
* Like [collectImmediately], but with three [StateFlow] instances that are collected with the same
* block.
*
* @param a The first [StateFlow] to collect.
* @param b The second [StateFlow] to collect.
* @param c The third [StateFlow] to collect.
* @param block The code to run when any of the [StateFlow]s update.
*/
fun <T1, T2, T3> Fragment.collectImmediately(
a: StateFlow<T1>,
b: StateFlow<T2>,
c: StateFlow<T3>,
block: (T1, T2, T3) -> Unit
) {
block(a.value, b.value, c.value)
val combine = combine(a, b, c) { a1, b2, c3 -> Triple(a1, b2, c3) }
launch { combine.collect { block(it.first, it.second, it.third) } }
}
/**
* Launch a [Fragment] co-routine whenever the [Lifecycle] hits the given [Lifecycle.State]. This
* should always been used when launching [Fragment] co-routines was it will not result in
* unexpected behavior.
*
* @param state The [Lifecycle.State] to launch the co-routine in.
* @param block The block to run in the co-routine.
* @see repeatOnLifecycle
*/
private fun Fragment.launch(
state: Lifecycle.State = Lifecycle.State.STARTED,
block: suspend CoroutineScope.() -> Unit
) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) }
}
/** /**
* Get the "System Bar" [Insets] in this [WindowInsets] instance in a version-compatible manner This * Get the "System Bar" [Insets] in this [WindowInsets] instance in a version-compatible manner This
* can be used to prevent [View] elements from intersecting with the navigation bars. * can be used to prevent [View] elements from intersecting with the navigation bars.

View file

@ -0,0 +1,123 @@
/*
* Copyright (c) 2023 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.util
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
interface Event<T> {
val flow: StateFlow<T?>
fun consume(): T?
}
class MutableEvent<T> : Event<T> {
override val flow = MutableStateFlow<T?>(null)
fun put(v: T) {
flow.value = v
}
override fun consume() = flow.value?.also { flow.value = null }
}
/**
* Collect a [StateFlow] into [block] in a lifecycle-aware manner *eventually.* Due to co-routine
* launching, the initializing call will occur ~100ms after draw time. If this is not desirable, use
* [collectImmediately].
*
* @param stateFlow The [StateFlow] to collect.
* @param block The code to run when the [StateFlow] updates.
*/
fun <T> Fragment.collect(stateFlow: StateFlow<T>, block: (T) -> Unit) {
launch { stateFlow.collect(block) }
}
/**
* Collect a [StateFlow] into a [block] in a lifecycle-aware manner *immediately.* This will
* immediately run an initializing call to ensure the UI is set up before draw-time. Note that this
* will result in two initializing calls.
*
* @param stateFlow The [StateFlow] to collect.
* @param block The code to run when the [StateFlow] updates.
*/
fun <T> Fragment.collectImmediately(stateFlow: StateFlow<T>, block: (T) -> Unit) {
block(stateFlow.value)
launch { stateFlow.collect(block) }
}
/**
* Like [collectImmediately], but with two [StateFlow] instances that are collected with the same
* block.
*
* @param a The first [StateFlow] to collect.
* @param b The second [StateFlow] to collect.
* @param block The code to run when either [StateFlow] updates.
*/
fun <T1, T2> Fragment.collectImmediately(
a: StateFlow<T1>,
b: StateFlow<T2>,
block: (T1, T2) -> Unit
) {
block(a.value, b.value)
// We can combine flows, but only if we transform them into one flow output.
// Thus, we have to first combine the two flow values into a Pair, and then
// decompose it when we collect the values.
val combine = a.combine(b) { first, second -> Pair(first, second) }
launch { combine.collect { block(it.first, it.second) } }
}
/**
* Like [collectImmediately], but with three [StateFlow] instances that are collected with the same
* block.
*
* @param a The first [StateFlow] to collect.
* @param b The second [StateFlow] to collect.
* @param c The third [StateFlow] to collect.
* @param block The code to run when any of the [StateFlow]s update.
*/
fun <T1, T2, T3> Fragment.collectImmediately(
a: StateFlow<T1>,
b: StateFlow<T2>,
c: StateFlow<T3>,
block: (T1, T2, T3) -> Unit
) {
block(a.value, b.value, c.value)
val combine = combine(a, b, c) { a1, b2, c3 -> Triple(a1, b2, c3) }
launch { combine.collect { block(it.first, it.second, it.third) } }
}
/**
* Launch a [Fragment] co-routine whenever the [Lifecycle] hits the given [Lifecycle.State]. This
* should always been used when launching [Fragment] co-routines was it will not result in
* unexpected behavior.
*
* @param state The [Lifecycle.State] to launch the co-routine in.
* @param block The block to run in the co-routine.
* @see repeatOnLifecycle
*/
private fun Fragment.launch(
state: Lifecycle.State = Lifecycle.State.STARTED,
block: suspend CoroutineScope.() -> Unit
) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) }
}

View file

@ -92,11 +92,9 @@ constructor(
} }
return if (cornerRadius > 0) { return if (cornerRadius > 0) {
// If rounded, educe the bitmap size further to obtain more pronounced // If rounded, reduce the bitmap size further to obtain more pronounced
// rounded corners. // rounded corners.
builder builder.transformations(
.size(getSafeRemoteViewsImageSize(context, 10f))
.transformations(
SquareFrameTransform.INSTANCE, SquareFrameTransform.INSTANCE,
RoundedCornersTransformation(cornerRadius.toFloat())) RoundedCornersTransformation(cornerRadius.toFloat()))
} else { } else {