From 86ca6d577e7f34ebebcadd3e72435cd3066fc5ea Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 4 Mar 2023 23:08:03 -0700 Subject: [PATCH] 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. --- .../auxio/detail/AlbumDetailFragment.kt | 8 +- .../auxio/detail/ArtistDetailFragment.kt | 8 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 88 ++++--- .../auxio/detail/GenreDetailFragment.kt | 8 +- .../oxycblt/auxio/detail/SongDetailDialog.kt | 6 +- .../auxio/detail/recycler/DetailAdapter.kt | 8 +- .../detail/recycler/SongPropertyAdapter.kt | 9 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 12 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 43 +++- .../auxio/home/list/AlbumListFragment.kt | 11 +- .../auxio/home/list/ArtistListFragment.kt | 12 +- .../auxio/home/list/GenreListFragment.kt | 12 +- .../auxio/home/list/SongListFragment.kt | 11 +- .../auxio/image/extractor/CoverExtractor.kt | 5 + .../oxycblt/auxio/list/adapter/DiffAdapter.kt | 56 ----- .../auxio/list/adapter/FlexibleListAdapter.kt | 237 ++++++++++++++++++ .../oxycblt/auxio/list/adapter/ListDiffer.kt | 232 ----------------- .../list/adapter/PlayingIndicatorAdapter.kt | 11 +- .../list/adapter/SelectionIndicatorAdapter.kt | 13 +- .../list/recycler/HeaderItemDecoration.kt | 4 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 28 ++- .../org/oxycblt/auxio/playback/queue/Queue.kt | 20 ++ .../auxio/playback/queue/QueueAdapter.kt | 8 +- .../auxio/playback/queue/QueueFragment.kt | 8 +- .../auxio/playback/queue/QueueViewModel.kt | 32 +-- .../auxio/playback/system/PlaybackService.kt | 3 - .../org/oxycblt/auxio/search/SearchAdapter.kt | 5 +- .../oxycblt/auxio/search/SearchFragment.kt | 3 +- .../org/oxycblt/auxio/util/FrameworkUtil.kt | 90 ------- .../java/org/oxycblt/auxio/util/StateUtil.kt | 123 +++++++++ .../oxycblt/auxio/widgets/WidgetComponent.kt | 10 +- 31 files changed, 585 insertions(+), 539 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/list/adapter/DiffAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index fd9bfe960..0ad5d4981 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -33,7 +33,6 @@ import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -104,6 +103,9 @@ class AlbumDetailFragment : super.onDestroyBinding(binding) binding.detailToolbar.setOnMenuItemClickListener(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 { @@ -273,8 +275,8 @@ class AlbumDetailFragment : } } - private fun updateList(items: List) { - detailAdapter.submitList(items, BasicListInstructions.DIFF) + private fun updateList(list: List) { + detailAdapter.update(list, detailModel.albumInstructions.consume()) } private fun updateSelection(selected: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index c1a8ab29f..ee3fdf8ab 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -33,7 +33,6 @@ import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -107,6 +106,9 @@ class ArtistDetailFragment : super.onDestroyBinding(binding) binding.detailToolbar.setOnMenuItemClickListener(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 { @@ -249,8 +251,8 @@ class ArtistDetailFragment : } } - private fun updateList(items: List) { - detailAdapter.submitList(items, BasicListInstructions.DIFF) + private fun updateList(list: List) { + detailAdapter.update(list, detailModel.artistInstructions.consume()) } private fun updateSelection(selected: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index d53a29b6a..036e8de69 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.Disc @@ -80,6 +81,10 @@ constructor( /** The current list data derived from [currentAlbum]. */ val albumList: StateFlow> get() = _albumList + private val _albumInstructions = MutableEvent() + /** Instructions for updating [albumList] in the UI. */ + val albumInstructions: Event + get() = _albumInstructions /** The current [Sort] used for [Song]s in [albumList]. */ var albumSongSort: Sort @@ -87,7 +92,7 @@ constructor( set(value) { musicSettings.albumSongSort = value // Refresh the album list to reflect the new sort. - currentAlbum.value?.let(::refreshAlbumList) + currentAlbum.value?.let { refreshAlbumList(it, true) } } // --- ARTIST --- @@ -100,6 +105,10 @@ constructor( private val _artistList = MutableStateFlow(listOf()) /** The current list derived from [currentArtist]. */ val artistList: StateFlow> = _artistList + private val _artistInstructions = MutableEvent() + /** Instructions for updating [artistList] in the UI. */ + val artistInstructions: Event + get() = _artistInstructions /** The current [Sort] used for [Song]s in [artistList]. */ var artistSongSort: Sort @@ -107,7 +116,7 @@ constructor( set(value) { musicSettings.artistSongSort = value // Refresh the artist list to reflect the new sort. - currentArtist.value?.let(::refreshArtistList) + currentArtist.value?.let { refreshArtistList(it, true) } } // --- GENRE --- @@ -120,6 +129,10 @@ constructor( private val _genreList = MutableStateFlow(listOf()) /** The current list data derived from [currentGenre]. */ val genreList: StateFlow> = _genreList + private val _genreInstructions = MutableEvent() + /** Instructions for updating [artistList] in the UI. */ + val genreInstructions: Event + get() = _genreInstructions /** The current [Sort] used for [Song]s in [genreList]. */ var genreSongSort: Sort @@ -127,7 +140,7 @@ constructor( set(value) { musicSettings.genreSongSort = value // 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 requireMusic(uid: Music.UID) = musicRepository.library?.find(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) { // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() @@ -259,10 +267,17 @@ constructor( } } - private fun refreshAlbumList(album: Album) { + private fun refreshAlbumList(album: Album, replace: Boolean = false) { logD("Refreshing album data") - val data = mutableListOf(album) - data.add(SortHeader(R.string.lbl_songs)) + val list = mutableListOf(album) + 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 // songs up by disc and then delimit the groups by a disc header. @@ -272,20 +287,21 @@ constructor( if (byDisc.size > 1) { logD("Album has more than one disc, interspersing headers") for (entry in byDisc.entries) { - data.add(entry.key) - data.addAll(entry.value) + list.add(entry.key) + list.addAll(entry.value) } } else { // 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") - val data = mutableListOf(artist) + val list = mutableListOf(artist) val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums) val byReleaseGroup = @@ -312,29 +328,43 @@ constructor( logD("Release groups for this artist: ${byReleaseGroup.keys}") for (entry in byReleaseGroup.entries.sortedBy { it.key }) { - data.add(BasicHeader(entry.key.headerTitleRes)) - data.addAll(entry.value) + list.add(BasicHeader(entry.key.headerTitleRes)) + list.addAll(entry.value) } // 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()) { logD("Songs present in this artist, adding header") - data.add(SortHeader(R.string.lbl_songs)) - data.addAll(artistSongSort.songs(artist.songs)) + list.add(SortHeader(R.string.lbl_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") - val data = mutableListOf(genre) + val list = mutableListOf(genre) // Genre is guaranteed to always have artists and songs. - data.add(BasicHeader(R.string.lbl_artists)) - data.addAll(genre.artists) - data.add(SortHeader(R.string.lbl_songs)) - data.addAll(genreSongSort.songs(genre.songs)) - _genreList.value = data + list.add(BasicHeader(R.string.lbl_artists)) + list.addAll(genre.artists) + 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 + } + list.addAll(genreSongSort.songs(genre.songs)) + _genreInstructions.put(instructions) + _genreList.value = list } /** diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index a032e9717..f5445b46d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -33,7 +33,6 @@ import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -106,6 +105,9 @@ class GenreDetailFragment : super.onDestroyBinding(binding) binding.detailToolbar.setOnMenuItemClickListener(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 { @@ -232,8 +234,8 @@ class GenreDetailFragment : } } - private fun updateList(items: List) { - detailAdapter.submitList(items, BasicListInstructions.DIFF) + private fun updateList(list: List) { + detailAdapter.update(list, detailModel.genreInstructions.consume()) } private fun updateSelection(selected: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index d5795ea9e..024897ca9 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSongDetailBinding import org.oxycblt.auxio.detail.recycler.SongProperty 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.Song import org.oxycblt.auxio.music.metadata.AudioInfo @@ -79,7 +79,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { if (info != null) { val context = requireContext() - detailAdapter.submitList( + detailAdapter.update( buildList { add(SongProperty(R.string.lbl_name, song.zipName(context))) add(SongProperty(R.string.lbl_album, song.album.zipName(context))) @@ -120,7 +120,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it))) } }, - BasicListInstructions.REPLACE) + UpdateInstructions.Replace(0)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt index 3adaaefa7..0830f282b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -39,16 +39,14 @@ import org.oxycblt.auxio.util.inflater * A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters. * * @param listener A [Listener] to bind interactions to. - * @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the - * internal list. + * @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with. * @author Alexander Capehart (OxygenCobalt) */ abstract class DetailAdapter( private val listener: Listener<*>, - diffCallback: DiffUtil.ItemCallback + private val diffCallback: DiffUtil.ItemCallback ) : - SelectionIndicatorAdapter( - ListDiffer.Async(diffCallback)), + SelectionIndicatorAdapter(diffCallback), AuxioRecyclerView.SpanSizeLookup { override fun getItemViewType(position: Int) = diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/SongPropertyAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/SongPropertyAdapter.kt index 104d05914..9a899eea3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/SongPropertyAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/SongPropertyAdapter.kt @@ -23,10 +23,7 @@ import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemSongPropertyBinding import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.adapter.BasicListInstructions -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.adapter.* import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater @@ -37,8 +34,8 @@ import org.oxycblt.auxio.util.inflater * @author Alexander Capehart (OxygenCobalt) */ class SongPropertyAdapter : - DiffAdapter( - ListDiffer.Blocking(SongPropertyViewHolder.DIFF_CALLBACK)) { + FlexibleListAdapter( + SongPropertyViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = SongPropertyViewHolder.from(parent) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index c6cc88e07..b84c912ee 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -154,7 +154,7 @@ class HomeFragment : binding.homeFab.setOnClickListener { playbackModel.shuffleAll() } // --- VIEWMODEL SETUP --- - collect(homeModel.shouldRecreate, ::handleRecreate) + collect(homeModel.shouldRecreate.flow, ::handleRecreate) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) collectImmediately(musicModel.indexerState, ::updateIndexerState) @@ -329,18 +329,14 @@ class HomeFragment : } } - private fun handleRecreate(recreate: Boolean) { - if (!recreate) { - // Nothing to do - return - } - + private fun handleRecreate(recreate: Unit?) { + if (recreate == null) return val binding = requireBinding() // Move back to position zero, as there must be a tab there. binding.homePager.currentItem = 0 // Make sure tabs are set up to also follow the new ViewPager configuration. setupPager(binding) - homeModel.finishRecreate() + homeModel.shouldRecreate.consume() } private fun updateIndexerState(state: Indexer.State?) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index c4afbc64a..d8b2c86b3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -24,9 +24,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.util.Event +import org.oxycblt.auxio.util.MutableEvent 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. */ val songsList: StateFlow> get() = _songsList + private val _songsInstructions = MutableEvent() + /** Instructions for how to update [songsList] in the UI. */ + val songsInstructions: Event + get() = _songsInstructions private val _albumsLists = MutableStateFlow(listOf()) /** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */ val albumsList: StateFlow> get() = _albumsLists + private val _albumsInstructions = MutableEvent() + /** Instructions for how to update [albumsList] in the UI. */ + val albumsInstructions: Event + get() = _albumsInstructions private val _artistsList = MutableStateFlow(listOf()) /** @@ -62,11 +73,19 @@ constructor( */ val artistsList: MutableStateFlow> get() = _artistsList + private val _artistsInstructions = MutableEvent() + /** Instructions for how to update [artistsList] in the UI. */ + val artistsInstructions: Event + get() = _artistsInstructions private val _genresList = MutableStateFlow(listOf()) /** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */ val genresList: StateFlow> get() = _genresList + private val _genresInstructions = MutableEvent() + /** Instructions for how to update [genresList] in the UI. */ + val genresInstructions: Event + get() = _genresInstructions /** The [MusicMode] to use when playing a [Song] from the UI. */ val playbackMode: MusicMode @@ -83,13 +102,14 @@ constructor( /** The [MusicMode] of the currently shown [Tab]. */ val currentTabMode: StateFlow = _currentTabMode - private val _shouldRecreate = MutableStateFlow(false) + private val _shouldRecreate = MutableEvent() /** * 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 * scratch. */ - val shouldRecreate: StateFlow = _shouldRecreate + val shouldRecreate: Event + get() = _shouldRecreate private val _isFastScrolling = MutableStateFlow(false) /** 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") // Get the each list of items in the library to use as our list data. // Applying the preferred sorting to them. + _songsInstructions.put(UpdateInstructions.Diff) _songsList.value = musicSettings.songSort.songs(library.songs) + _albumsInstructions.put(UpdateInstructions.Diff) _albumsLists.value = musicSettings.albumSort.albums(library.albums) + _artistsInstructions.put(UpdateInstructions.Diff) _artistsList.value = musicSettings.artistSort.artists( if (homeSettings.shouldHideCollaborators) { @@ -121,6 +144,7 @@ constructor( } else { library.artists }) + _genresInstructions.put(UpdateInstructions.Diff) _genresList.value = musicSettings.genreSort.genres(library.genres) } } @@ -128,7 +152,7 @@ constructor( override fun onTabsChanged() { // Tabs changed, update the current tabs and set up a re-create event. currentTabModes = makeTabModes() - _shouldRecreate.value = true + _shouldRecreate.put(Unit) } override fun onHideCollaboratorsChanged() { @@ -162,18 +186,22 @@ constructor( when (_currentTabMode.value) { MusicMode.SONGS -> { musicSettings.songSort = sort + _songsInstructions.put(UpdateInstructions.Replace(0)) _songsList.value = sort.songs(_songsList.value) } MusicMode.ALBUMS -> { musicSettings.albumSort = sort + _albumsInstructions.put(UpdateInstructions.Replace(0)) _albumsLists.value = sort.albums(_albumsLists.value) } MusicMode.ARTISTS -> { musicSettings.artistSort = sort + _artistsInstructions.put(UpdateInstructions.Replace(0)) _artistsList.value = sort.artists(_artistsList.value) } MusicMode.GENRES -> { musicSettings.genreSort = sort + _genresInstructions.put(UpdateInstructions.Replace(0)) _genresList.value = sort.genres(_genresList.value) } } @@ -189,15 +217,6 @@ constructor( _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. * diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index b937cc0b6..df15ce026 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -32,8 +32,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment 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.recycler.AlbumViewHolder import org.oxycblt.auxio.list.selection.SelectionViewModel @@ -76,7 +74,7 @@ class AlbumListFragment : listener = this@AlbumListFragment } - collectImmediately(homeModel.albumsList, ::updateList) + collectImmediately(homeModel.albumsList, ::updateAlbums) collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -140,8 +138,8 @@ class AlbumListFragment : openMusicMenu(anchor, R.menu.menu_album_actions, item) } - private fun updateList(albums: List) { - albumAdapter.submitList(albums, BasicListInstructions.REPLACE) + private fun updateAlbums(albums: List) { + albumAdapter.update(albums, homeModel.albumsInstructions.consume()) } private fun updateSelection(selection: List) { @@ -159,8 +157,7 @@ class AlbumListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class AlbumAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter( - ListDiffer.Blocking(AlbumViewHolder.DIFF_CALLBACK)) { + SelectionIndicatorAdapter(AlbumViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AlbumViewHolder.from(parent) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index a355c2389..bebd28b1b 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -30,8 +30,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment 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.recycler.ArtistViewHolder 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.ui.NavigationViewModel import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.nonZeroOrNull /** @@ -74,7 +73,7 @@ class ArtistListFragment : listener = this@ArtistListFragment } - collectImmediately(homeModel.artistsList, ::updateList) + collectImmediately(homeModel.artistsList, ::updateArtists) collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -118,8 +117,8 @@ class ArtistListFragment : openMusicMenu(anchor, R.menu.menu_artist_actions, item) } - private fun updateList(artists: List) { - artistAdapter.submitList(artists, BasicListInstructions.REPLACE) + private fun updateArtists(artists: List) { + artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) }) } private fun updateSelection(selection: List) { @@ -137,8 +136,7 @@ class ArtistListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class ArtistAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter( - ListDiffer.Blocking(ArtistViewHolder.DIFF_CALLBACK)) { + SelectionIndicatorAdapter(ArtistViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ArtistViewHolder.from(parent) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index a70f41e53..3683391f6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -30,8 +30,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment 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.recycler.GenreViewHolder 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.ui.NavigationViewModel import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD /** * A [ListFragment] that shows a list of [Genre]s. @@ -73,7 +72,7 @@ class GenreListFragment : listener = this@GenreListFragment } - collectImmediately(homeModel.genresList, ::updateList) + collectImmediately(homeModel.genresList, ::updateGenres) collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -117,8 +116,8 @@ class GenreListFragment : openMusicMenu(anchor, R.menu.menu_artist_actions, item) } - private fun updateList(artists: List) { - genreAdapter.submitList(artists, BasicListInstructions.REPLACE) + private fun updateGenres(genres: List) { + genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) }) } private fun updateSelection(selection: List) { @@ -136,8 +135,7 @@ class GenreListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class GenreAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter( - ListDiffer.Blocking(GenreViewHolder.DIFF_CALLBACK)) { + SelectionIndicatorAdapter(GenreViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = GenreViewHolder.from(parent) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 164d036c7..82f7d2eb3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -32,8 +32,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment 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.recycler.SongViewHolder import org.oxycblt.auxio.list.selection.SelectionViewModel @@ -79,7 +77,7 @@ class SongListFragment : listener = this@SongListFragment } - collectImmediately(homeModel.songsList, ::updateList) + collectImmediately(homeModel.songsList, ::updateSongs) collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) @@ -147,8 +145,8 @@ class SongListFragment : openMusicMenu(anchor, R.menu.menu_song_actions, item) } - private fun updateList(songs: List) { - songAdapter.submitList(songs, BasicListInstructions.REPLACE) + private fun updateSongs(songs: List) { + songAdapter.update(songs, homeModel.songsInstructions.consume()) } private fun updateSelection(selection: List) { @@ -170,8 +168,7 @@ class SongListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class SongAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter( - ListDiffer.Blocking(SongViewHolder.DIFF_CALLBACK)) { + SelectionIndicatorAdapter(SongViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = SongViewHolder.from(parent) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index b5ef3ecac..270d4eba4 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -37,6 +37,11 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW +/** + * Stateless interface for loading [Album] cover image data. + * + * @author Alexander Capehart (OxygenCobalt) + */ interface CoverExtractor { /** * Fetch an album cover, respecting the current cover configuration. diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/DiffAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/DiffAdapter.kt deleted file mode 100644 index cbedc49b7..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/DiffAdapter.kt +++ /dev/null @@ -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 . - */ - -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( - differFactory: ListDiffer.Factory -) : RecyclerView.Adapter() { - private val differ = differFactory.new(@Suppress("LeakingThis") this) - - final override fun getItemCount() = differ.currentList.size - - /** The current list of [T] items. */ - val currentList: List - 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, instructions: I, onDone: () -> Unit = {}) { - differ.submitList(newList, instructions, onDone) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt new file mode 100644 index 000000000..73d8de99d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt @@ -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 . + */ + +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( + diffCallback: DiffUtil.ItemCallback +) : RecyclerView.Adapter() { + 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 + 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, + 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( + adapter: RecyclerView.Adapter<*>, + diffCallback: DiffUtil.ItemCallback +) { + 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() + private set + + private var maxScheduledGeneration = 0 + + fun update(newList: List, 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, + newList: List, + 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() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt deleted file mode 100644 index 1a0851ee8..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt +++ /dev/null @@ -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 . - */ - -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 { - /** The current list of [T] items. */ - val currentList: List - - /** - * 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, 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 { - /** - * 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 - } - - /** - * 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(private val diffCallback: DiffUtil.ItemCallback) : - Factory() { - override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer = - 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(private val diffCallback: DiffUtil.ItemCallback) : - Factory() { - override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer = - 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 : ListDiffer { - override fun submitList( - newList: List, - instructions: BasicListInstructions, - onDone: () -> Unit - ) { - when (instructions) { - BasicListInstructions.DIFF -> diffList(newList, onDone) - BasicListInstructions.REPLACE -> replaceList(newList, onDone) - } - } - - protected abstract fun diffList(newList: List, onDone: () -> Unit) - protected abstract fun replaceList(newList: List, onDone: () -> Unit) -} - -private class AsyncListDifferImpl( - updateCallback: ListUpdateCallback, - diffCallback: DiffUtil.ItemCallback -) : BasicListDiffer() { - private val inner = - AsyncListDiffer(updateCallback, AsyncDifferConfig.Builder(diffCallback).build()) - - override val currentList: List - get() = inner.currentList - - override fun diffList(newList: List, onDone: () -> Unit) { - inner.submitList(newList, onDone) - } - - override fun replaceList(newList: List, onDone: () -> Unit) { - inner.submitList(null) { inner.submitList(newList, onDone) } - } -} - -private class BlockingListDifferImpl( - private val updateCallback: ListUpdateCallback, - private val diffCallback: DiffUtil.ItemCallback -) : BasicListDiffer() { - override var currentList = listOf() - - override fun diffList(newList: List, 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, onDone: () -> Unit) { - if (currentList != newList) { - diffList(listOf()) { diffList(newList, onDone) } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt index b4a66d000..4534b6dcc 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt @@ -18,18 +18,19 @@ package org.oxycblt.auxio.list.adapter import android.view.View +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.util.logD /** * 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) */ -abstract class PlayingIndicatorAdapter( - differFactory: ListDiffer.Factory -) : DiffAdapter(differFactory) { +abstract class PlayingIndicatorAdapter( + diffCallback: DiffUtil.ItemCallback +) : FlexibleListAdapter(diffCallback) { // There are actually two states for this adapter: // - 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 @@ -40,7 +41,7 @@ abstract class PlayingIndicatorAdapter( override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { // Only try to update the playing indicator if the ViewHolder supports it if (holder is ViewHolder) { - holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying) + holder.updatePlayingIndicator(getItem(position) == currentItem, isPlaying) } if (payloads.isEmpty()) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt index 30e15468e..348c0057b 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.list.adapter import android.view.View +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView 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 * 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) */ -abstract class SelectionIndicatorAdapter( - differFactory: ListDiffer.Factory -) : PlayingIndicatorAdapter(differFactory) { +abstract class SelectionIndicatorAdapter( + diffCallback: DiffUtil.ItemCallback +) : PlayingIndicatorAdapter(diffCallback) { private var selectedItems = setOf() override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { @@ -64,9 +65,7 @@ abstract class SelectionIndicatorAdapter( } // Only update items that were added or removed from the list. - val added = !oldSelectedItems.contains(item) && newSelectedItems.contains(item) - val removed = oldSelectedItems.contains(item) && !newSelectedItems.contains(item) - if (added || removed) { + if (oldSelectedItems.contains(item) xor newSelectedItems.contains(item)) { notifyItemChanged(i, PAYLOAD_SELECTION_INDICATOR_CHANGED) } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt index 831b8aff4..6f6ff5058 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt @@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.divider.BackportMaterialDividerItemDecoration import org.oxycblt.auxio.R 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 @@ -45,7 +45,7 @@ constructor( // 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 // 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) { false } catch (e: IndexOutOfBoundsException) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index b1a908f76..afb592fa8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -29,20 +29,35 @@ import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.util.logD 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 { + /** + * Poll to see if this worker is done processing. + * + * @return A completed [RawSong] if done, null otherwise. + */ fun poll(): RawSong? + /** Factory for new [TagWorker] jobs. */ 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 } } -class TagWorkerImpl(private val rawSong: RawSong, private val future: Future) : +class TagWorkerImpl +private constructor(private val rawSong: RawSong, private val future: Future) : 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. * @@ -246,6 +261,9 @@ class TagWorkerImpl(private val rawSong: RawSong, private val future: Future() @Volatile private var orderedMapping = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index c83aaf263..ade55e065 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -27,10 +27,7 @@ import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemQueueSongBinding import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.list.adapter.BasicListInstructions -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.adapter.* import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames @@ -43,8 +40,7 @@ import org.oxycblt.auxio.util.* * @author Alexander Capehart (OxygenCobalt) */ class QueueAdapter(private val listener: EditableListListener) : - DiffAdapter( - ListDiffer.Blocking(QueueSongViewHolder.DIFF_CALLBACK)) { + FlexibleListAdapter(QueueSongViewHolder.DIFF_CALLBACK) { // 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 // with an index value instead. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 753503825..427c5244d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -28,7 +28,6 @@ import dagger.hilt.android.AndroidEntryPoint import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -102,13 +101,12 @@ class QueueFragment : ViewBindingFragment(), EditableListL val binding = requireBinding() // Replace or diff the queue depending on the type of change it is. - val instructions = queueModel.queueListInstructions - queueAdapter.submitList(queue, instructions?.update ?: BasicListInstructions.DIFF) + queueAdapter.update(queue, queueModel.queueInstructions.consume()) // Update position in list (and thus past/future items) queueAdapter.setPosition(index, isPlaying) // If requested, scroll to a new item (occurs when the index moves) - val scrollTo = instructions?.scrollTo + val scrollTo = queueModel.scrollTo.consume() if (scrollTo != null) { val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager val start = lmm.findFirstCompletelyVisibleItemPosition() @@ -129,7 +127,5 @@ class QueueFragment : ViewBindingFragment(), EditableListL min(queue.lastIndex, scrollTo + (end - start))) } } - - queueModel.finishInstructions() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 8e42063fb..377381305 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -22,10 +22,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow 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.Song 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. @@ -39,27 +41,32 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt private val _queue = MutableStateFlow(listOf()) /** The current queue. */ val queue: StateFlow> = _queue + private val _queueInstructions = MutableEvent() + /** Instructions for how to update [queue] in the UI. */ + val queueInstructions: Event = _queueInstructions + private val _scrollTo = MutableEvent() + /** Controls whether the queue should be force-scrolled to a particular location. */ + val scrollTo: Event + get() = _scrollTo private val _index = MutableStateFlow(playbackManager.queue.index) /** The index of the currently playing song in the queue. */ val index: StateFlow get() = _index - /** Specifies how to update the list when the queue changes. */ - var queueListInstructions: ListInstructions? = null - init { playbackManager.addListener(this) } override fun onIndexMoved(queue: Queue) { - queueListInstructions = ListInstructions(null, queue.index) + _scrollTo.put(queue.index) _index.value = queue.index } override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { // 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() if (change != Queue.ChangeResult.MAPPING) { // 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) { // 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() _index.value = queue.index } override fun onNewPlayback(queue: Queue, parent: MusicParent?) { // 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() _index.value = queue.index } @@ -123,11 +132,4 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt playbackManager.moveQueueItem(adapterFrom, adapterTo) return true } - - /** Signal that the specified [ListInstructions] in [queueListInstructions] were performed. */ - fun finishInstructions() { - queueListInstructions = null - } - - class ListInstructions(val update: BasicListInstructions?, val scrollTo: Int?) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 8c131de50..201ad6abf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -114,9 +114,6 @@ class PlaybackService : override fun 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 // battery/apk size/cache size val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 113bbdecf..3e896b2a3 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -20,8 +20,6 @@ package org.oxycblt.auxio.search import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView 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.SimpleDiffCallback import org.oxycblt.auxio.list.recycler.* @@ -35,8 +33,7 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class SearchAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter( - ListDiffer.Async(DIFF_CALLBACK)), + SelectionIndicatorAdapter(DIFF_CALLBACK), AuxioRecyclerView.SpanSizeLookup { override fun getItemViewType(position: Int) = diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index ccf5f4e30..152cb7b13 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -34,7 +34,6 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -163,7 +162,7 @@ class SearchFragment : ListFragment() { // Don't show the RecyclerView (and it's stray overscroll effects) when there // are no results. 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 // the query actually changes instead of once every re-creation event, but sadly // that doesn't seem possible. diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index e66a52207..14c7827aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -28,16 +28,8 @@ import androidx.appcompat.widget.AppCompatButton import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.graphics.Insets 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.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. @@ -127,88 +119,6 @@ fun AppCompatButton.fixDoubleRipple() { val View.coordinatorLayoutBehavior: CoordinatorLayout.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 Fragment.collect(stateFlow: StateFlow, 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 Fragment.collectImmediately(stateFlow: StateFlow, 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 Fragment.collectImmediately( - a: StateFlow, - b: StateFlow, - 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 Fragment.collectImmediately( - a: StateFlow, - b: StateFlow, - c: StateFlow, - 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 * can be used to prevent [View] elements from intersecting with the navigation bars. diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt new file mode 100644 index 000000000..de52722d6 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -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 . + */ + +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 { + val flow: StateFlow + fun consume(): T? +} + +class MutableEvent : Event { + override val flow = MutableStateFlow(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 Fragment.collect(stateFlow: StateFlow, 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 Fragment.collectImmediately(stateFlow: StateFlow, 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 Fragment.collectImmediately( + a: StateFlow, + b: StateFlow, + 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 Fragment.collectImmediately( + a: StateFlow, + b: StateFlow, + c: StateFlow, + 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) } +} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 9c1e9eab6..2cf17df44 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -92,13 +92,11 @@ constructor( } 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. - builder - .size(getSafeRemoteViewsImageSize(context, 10f)) - .transformations( - SquareFrameTransform.INSTANCE, - RoundedCornersTransformation(cornerRadius.toFloat())) + builder.transformations( + SquareFrameTransform.INSTANCE, + RoundedCornersTransformation(cornerRadius.toFloat())) } else { builder.size(getSafeRemoteViewsImageSize(context)) }