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)) }