From 2efdba5f3669579701a7a26a510fdf97aab7df02 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-bird.bird@users.noreply.gitlab.com> Date: Sun, 4 Oct 2026 17:39:42 +0000 Subject: [PATCH] Clean MVC boundary transgressions --- .../di/AppPermanentStorageModule.kt | 2 +- .../ultrasonic/fragment/AlbumListFragment.kt | 141 ++++++++---------- .../ultrasonic/fragment/BookmarksFragment.kt | 8 +- .../moire/ultrasonic/fragment/MainFragment.kt | 43 +++--- .../ultrasonic/fragment/SearchFragment.kt | 15 +- .../fragment/TrackCollectionFragment.kt | 39 +++-- .../moire/ultrasonic/model/AlbumListModel.kt | 96 +++++++++++- .../ultrasonic/model/GenericListModel.kt | 27 ++++ .../moire/ultrasonic/model/SearchListModel.kt | 15 +- 9 files changed, 250 insertions(+), 136 deletions(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt index 2dec5bb2..fbfe7195 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/AppPermanentStorageModule.kt @@ -70,7 +70,7 @@ val appPermanentStorage = module { viewModel { GenreListModel(get(), get(), get(), get()) } viewModel { PlaylistListModel(get(), get(), get(), get(), get()) } viewModel { PodcastListModel(get(), get(), get(), get()) } - viewModel { SearchListModel(get(), get(), get(), get()) } + viewModel { SearchListModel(get(), get(), get(), get(), get()) } viewModel { ShareListModel(get(), get(), get(), get()) } viewModel { TrackCollectionModel(get(), get(), get(), get(), get()) } viewModel { DownloadListModel(get(), get(), get(), get(), get()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt index a59ab04c..f13de320 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -12,13 +12,11 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.lifecycle.LiveData -import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.NavigationGraphDirections @@ -26,26 +24,29 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.AlbumGridDelegate import org.moire.ultrasonic.adapters.AlbumRowDelegate import org.moire.ultrasonic.adapters.PopupMenuFactory -import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Album import org.moire.ultrasonic.model.AlbumListModel import org.moire.ultrasonic.util.LayoutType import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.toastingExceptionHandler import org.moire.ultrasonic.view.FilterButtonBar import org.moire.ultrasonic.view.SortOrder import org.moire.ultrasonic.view.ViewCapabilities /** * Displays a list of Albums from the media library + * + * Initial layout and sort order are passed through the fragment's arguments, never through + * constructor parameters: OS-driven recreation instantiates fragments with the default + * constructor, and only arguments survive that. */ -class AlbumListFragment( - private var layoutType: LayoutType = LayoutType.LIST, - private var orderType: SortOrder? = null -) : EntryListFragment(), +class AlbumListFragment : + EntryListFragment(), FilterableFragment { + private var layoutType: LayoutType = LayoutType.LIST + private var orderType: SortOrder? = null + private val popupMenuFactory: PopupMenuFactory by inject() private var filterButtonBar: FilterButtonBar? = null @@ -83,57 +84,21 @@ class AlbumListFragment( append: Boolean = navArgs.append, newSortOrderChosen: Boolean = false ) { - listModel.viewModelScope.launch( - toastingExceptionHandler() - ) { - listModel.withDelayedLoadingIndicator { - if (navArgs.byArtist) { - listModel.getAlbumsOfArtist( - id = navArgs.id!!, - name = navArgs.title - ) - } else if (orderType == SortOrder.BY_GENRE) { - fetchAlbumsByGenre(refresh, append, newSortOrderChosen) - } else { - listModel.getAlbums( - albumListType = orderType?.mapToAlbumListType() ?: navArgs.type, - size = navArgs.size, - offset = navArgs.offset, - append = append, - refresh = refresh or append - ) - } - } - } - } - - private suspend fun fetchAlbumsByGenre( - refresh: Boolean, - append: Boolean, - newSortOrderChosen: Boolean - ) { - if (selectedGenre != null && !newSortOrderChosen) { - listModel.getAlbums( - albumListType = AlbumListType.BY_GENRE, - size = navArgs.size, - offset = navArgs.offset, - append = append, - refresh = refresh or append, - genre = selectedGenre - ) - swipeRefresh?.isRefreshing = false - return - } - val genres = listModel.getGenres(true) - if (genres.isEmpty()) { - swipeRefresh?.isRefreshing = false - return - } - val genreStrings = genres.map { it.name }.toTypedArray() - if (childFragmentManager.findFragmentByTag(ItemSelectionDialogFragment.TAG) == null) { - ItemSelectionDialogFragment.create(R.string.main_genres_title, genreStrings) - .show(childFragmentManager, ItemSelectionDialogFragment.TAG) - } + // All branching and coroutine handling lives in the model; the fragment only + // forwards its state. + listModel.fetchAlbums( + byArtist = navArgs.byArtist, + artistId = navArgs.id, + artistName = navArgs.title, + orderType = orderType, + fallbackListType = navArgs.type, + size = navArgs.size, + offset = navArgs.offset, + refresh = refresh, + append = append, + selectedGenre = selectedGenre, + newSortOrderChosen = newSortOrderChosen + ) } override fun setLayoutType(newType: LayoutType) { @@ -161,6 +126,10 @@ class AlbumListFragment( override fun setOrderType(newOrder: SortOrder) { orderType = newOrder + // Persist to the arguments as well: instance state alone is lost on process death, + // and Settings is only written for the main Albums tab. + arguments?.putString(ARG_ORDER_TYPE, newOrder.name) + // Only persist the sort order for the main Albums tab (not artist-specific views) if (!navArgs.byArtist) { Settings.lastAlbumSortOrder = newOrder.name @@ -168,7 +137,7 @@ class AlbumListFragment( // If we are on an Artist page we just need to reorder the list. Otherwise refetch if (navArgs.byArtist) { - listModel.sortListByOrder(newOrder.mapToAlbumListType()) + listModel.sortListByOrder(newOrder) } else { fetchAlbums(refresh = true, append = false, newSortOrderChosen = true) } @@ -222,12 +191,16 @@ class AlbumListFragment( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (savedInstanceState != null) { - val orderTypeName = savedInstanceState.getString("order_type") - if (orderTypeName != null) { - orderType = SortOrder.valueOf(orderTypeName) - } - selectedGenre = savedInstanceState.getString("selected_genre") + + // Initial state comes from the arguments (written by the creator of the fragment + // and preserved by the OS across recreation), then the instance state wins if present. + arguments?.let { args -> + args.getString(ARG_LAYOUT_TYPE)?.let { layoutType = LayoutType.valueOf(it) } + args.getString(ARG_ORDER_TYPE)?.let { orderType = SortOrder.fromName(it) } + } + savedInstanceState?.let { state -> + state.getString(ARG_ORDER_TYPE)?.let { orderType = SortOrder.fromName(it) } + selectedGenre = state.getString(ARG_SELECTED_GENRE) } } @@ -260,6 +233,18 @@ class AlbumListFragment( } } + // The model requests the genre selection dialog whenever a fetch needs the user + // to pick a genre ("By Genre" in the orderType dropdown menu). + listModel.genreSelectionRequest.observe(viewLifecycleOwner) { genres -> + if (genres.isEmpty()) return@observe + if (childFragmentManager.findFragmentByTag(ItemSelectionDialogFragment.TAG) == null) { + ItemSelectionDialogFragment.create( + R.string.main_genres_title, + genres.toTypedArray() + ).show(childFragmentManager, ItemSelectionDialogFragment.TAG) + } + } + // Setup refresh handler swipeRefresh = view.findViewById(refreshListId) swipeRefresh?.setOnRefreshListener { @@ -308,8 +293,8 @@ class AlbumListFragment( override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString("order_type", orderType?.name) - outState.putString("selected_genre", selectedGenre) + outState.putString(ARG_ORDER_TYPE, orderType?.name) + outState.putString(ARG_SELECTED_GENRE, selectedGenre) } private fun setupFilterBar(view: View) { @@ -343,21 +328,15 @@ class AlbumListFragment( findNavController().navigate(action) } - private fun SortOrder.mapToAlbumListType(): AlbumListType = when (this) { - SortOrder.RANDOM -> AlbumListType.RANDOM - SortOrder.NEWEST -> AlbumListType.NEWEST - SortOrder.HIGHEST -> AlbumListType.HIGHEST - SortOrder.FREQUENT -> AlbumListType.FREQUENT - SortOrder.RECENT -> AlbumListType.RECENT - SortOrder.BY_NAME -> AlbumListType.SORTED_BY_NAME - SortOrder.BY_ARTIST -> AlbumListType.SORTED_BY_ARTIST - SortOrder.BY_GENRE -> AlbumListType.BY_GENRE - SortOrder.STARRED -> AlbumListType.STARRED - SortOrder.BY_YEAR -> AlbumListType.BY_YEAR - } - companion object { private const val ROWS = 3 private const val ALBUM_VISIBLE_THRESHOLD = 20 + + /** Key for the initial [LayoutType], stored in the arguments by the creator. */ + const val ARG_LAYOUT_TYPE = "album_list_layout_type" + + /** Key for the sort order override, stored in the arguments and instance state. */ + const val ARG_ORDER_TYPE = "album_list_order_type" + private const val ARG_SELECTED_GENRE = "album_list_selected_genre" } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt index 5030064d..921fb89b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt @@ -11,7 +11,6 @@ import android.os.Bundle import android.view.View import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.BaseAdapter @@ -19,7 +18,6 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.service.QueueInsertionMode -import org.moire.ultrasonic.util.toastingExceptionHandler /** * Lists the Bookmarks available on the server @@ -42,12 +40,8 @@ class BookmarksFragment : TrackCollectionFragment() { refresh: Boolean, append: Boolean ): LiveData> { - listModel.viewModelScope.launch( - toastingExceptionHandler() - ) { - swipeRefresh?.isRefreshing = true + listModel.launchWithLoadingIndicator { listModel.getBookmarks() - swipeRefresh?.isRefreshing = false } return listModel.currentList } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt index 6acfe38f..2074498a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt @@ -193,27 +193,18 @@ class MusicCollectionAdapter(fragment: Fragment, initialType: LayoutType = Layou val fragment = when (position) { 0 -> ArtistListFragment() - - 1 -> { - val savedOrder = Settings.lastAlbumSortOrder - .takeIf { it.isNotEmpty() } - ?.let { SortOrder.fromName(it) } - AlbumListFragment(layoutType, savedOrder) - } - - 2 -> { - val savedOrder = Settings.lastSongsSortOrder - .takeIf { it.isNotEmpty() } - ?.let { SortOrder.fromName(it) } - ?: SortOrder.RANDOM - TrackCollectionFragment(savedOrder) - } - + 1 -> AlbumListFragment() + 2 -> TrackCollectionFragment() else -> SelectGenreFragment() } + // Initial state (layout type / sort order overrides) is passed through the arguments, + // not constructor parameters: OS-driven recreation instantiates fragments with the + // default constructor, and only the arguments survive that. + val args = action.arguments + applyInitialFragmentState(position, args) + fragment.arguments = args fragmentMap[position] = SoftReference(fragment) - fragment.arguments = action.arguments // See comment in onPageSelected if (propagateCapabilitiesMatcher == position) { @@ -232,6 +223,24 @@ class MusicCollectionAdapter(fragment: Fragment, initialType: LayoutType = Layou 3 -> context.getString(R.string.main_genres_title) else -> "Unknown" } + + private fun applyInitialFragmentState(position: Int, args: Bundle) { + when (position) { + 1 -> { + args.putString(AlbumListFragment.ARG_LAYOUT_TYPE, layoutType.name) + Settings.lastAlbumSortOrder + .takeIf { it.isNotEmpty() } + ?.let { args.putString(AlbumListFragment.ARG_ORDER_TYPE, it) } + } + + 2 -> { + val savedOrder = Settings.lastSongsSortOrder + .takeIf { it.isNotEmpty() } + ?: SortOrder.RANDOM.name + args.putString(TrackCollectionFragment.ARG_SORT_ORDER, savedOrder) + } + } + } } interface FilterableFragment { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index 7d48f485..8143db51 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -11,11 +11,9 @@ import android.os.Bundle import android.view.MenuItem import android.view.View import androidx.core.view.isVisible -import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.R @@ -37,14 +35,12 @@ import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.model.SearchListModel import org.moire.ultrasonic.service.DownloadManager -import org.moire.ultrasonic.service.PlaybackQueueActions import org.moire.ultrasonic.service.PlaybackRepository import org.moire.ultrasonic.subsonic.VideoPlayer import org.moire.ultrasonic.util.ContextMenuUtil import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.UiUtil import org.moire.ultrasonic.util.UiUtil.toast -import org.moire.ultrasonic.util.toastingExceptionHandler /** * Initiates a search on the media library and displays the results @@ -55,7 +51,6 @@ class SearchFragment : RefreshableFragment { private var searchResult: SearchResult? = null override var swipeRefresh: SwipeRefreshLayout? = null - private val playbackQueue: PlaybackQueueActions by inject() private val playback: PlaybackRepository by inject() private val downloadManager: DownloadManager by inject() private val popupMenuFactory: PopupMenuFactory by inject() @@ -142,12 +137,8 @@ class SearchFragment : } private fun search(query: String, autoplay: Boolean) { - listModel.viewModelScope.launch( - toastingExceptionHandler() - ) { - swipeRefresh?.isRefreshing = true + listModel.launchWithLoadingIndicator { val result = listModel.search(query) - swipeRefresh?.isRefreshing = false if (result != null && autoplay) { autoplay() } @@ -235,9 +226,7 @@ class SearchFragment : } private fun onSongSelected(song: Track, append: Boolean) { - listModel.viewModelScope.launch { - playbackQueue.playAppended(song, replaceQueue = !append) - } + listModel.playSong(song, append) toast(resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1)) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 7e2efea1..40951ae3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -18,7 +18,6 @@ import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager @@ -57,7 +56,6 @@ import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.UiUtil.navigateToCurrent import org.moire.ultrasonic.util.UiUtil.toast -import org.moire.ultrasonic.util.toastingExceptionHandler import org.moire.ultrasonic.view.SortOrder import org.moire.ultrasonic.view.ViewCapabilities import timber.log.Timber @@ -68,9 +66,13 @@ import timber.log.Timber * In most cases the data should be just a list of Entries, but there are some cases * where the list can contain Albums as well. This happens especially when having ID3 tags disabled, * or using Offline mode, both in which Indexes instead of Artists are being used. + * + * The initial sort order is passed through the fragment's arguments, never through a + * constructor parameter: OS-driven recreation instantiates fragments with the default + * constructor, and only arguments survive that. */ @Suppress("TooManyFunctions") -open class TrackCollectionFragment(initialOrder: SortOrder? = null) : +open class TrackCollectionFragment : MultiListFragment(), FilterableFragment { @@ -99,7 +101,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) : override val listModel: TrackCollectionModel by viewModel() - private var sortOrder = initialOrder + private var sortOrder: SortOrder? = null /** * The id of the main layout @@ -110,8 +112,11 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val sortOrderName = savedInstanceState?.getString("sort_order") - sortOrderName?.let { sortOrder = SortOrder.valueOf(it) } + + // Initial state comes from the arguments (written by the creator of the fragment + // and preserved by the OS across recreation), then the instance state wins if present. + arguments?.getString(ARG_SORT_ORDER)?.let { sortOrder = SortOrder.fromName(it) } + savedInstanceState?.getString(ARG_SORT_ORDER)?.let { sortOrder = SortOrder.fromName(it) } } @Suppress("LongMethod") @@ -206,7 +211,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString("sort_order", sortOrder?.name) + outState.putString(ARG_SORT_ORDER, sortOrder?.name) } private fun loadMoreTracks() { @@ -569,11 +574,9 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) : val refresh2 = navArgs.refresh || refresh if (refresh || append || listModel.currentList.value == null) { - listModel.viewModelScope.launch( - toastingExceptionHandler() - ) { - swipeRefresh?.isRefreshing = true - + // The whole branching and the coroutine itself live in the model; the fragment + // only forwards its state and updates the title along the way. + listModel.launchWithLoadingIndicator { if (playlistId != null) { setTitle(playlistName!!) listModel.getPlaylist(playlistId, playlistName) @@ -606,8 +609,6 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) : listModel.getMusicDirectory(refresh2, id, name) } } - - swipeRefresh?.isRefreshing = false } } return listModel.currentList @@ -686,6 +687,11 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) : override fun setOrderType(newOrder: SortOrder) { sortOrder = newOrder + + // Persist to the arguments as well: instance state alone is lost on process death, + // and Settings is only written for the main Songs tab. + arguments?.putString(ARG_SORT_ORDER, newOrder.name) + val isSongsTab = navArgs.playlistId == null && navArgs.podcastChannelId == null && navArgs.shareId == null @@ -709,4 +715,9 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) : } return supported } + + companion object { + /** Key for the sort order override, stored in the arguments and instance state. */ + const val ARG_SORT_ORDER = "track_collection_sort_order" + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt index c5cdc2d9..388e6120 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt @@ -19,6 +19,7 @@ import org.moire.ultrasonic.service.CachedMusicService import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.util.CommunicationError +import org.moire.ultrasonic.view.SortOrder class AlbumListModel( application: Application, @@ -28,9 +29,83 @@ class AlbumListModel( ) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) { val list: MutableLiveData> = MutableLiveData() + + /** + * Set when a load requires the user to pick a genre; the observing fragment + * displays the selection dialog. Empty means no selection is needed. + */ + val genreSelectionRequest: MutableLiveData> = MutableLiveData() + private var lastType: AlbumListType? = null private var loadedUntil: Int = 0 + /** + * Central entry point for loading the album list: launches on the ViewModel scope and + * owns the branching between albums of an artist, genre selection and plain album lists. + */ + @Suppress("LongParameterList") + fun fetchAlbums( + byArtist: Boolean, + artistId: String?, + artistName: String?, + orderType: SortOrder?, + fallbackListType: AlbumListType, + size: Int, + offset: Int, + refresh: Boolean, + append: Boolean, + selectedGenre: String?, + newSortOrderChosen: Boolean = false + ) { + launchWithLoadingIndicator { + if (byArtist) { + getAlbumsOfArtist(artistId!!, artistName) + } else if (orderType == SortOrder.BY_GENRE) { + fetchAlbumsByGenre( + refresh = refresh, + append = append, + size = size, + offset = offset, + selectedGenre = selectedGenre, + newSortOrderChosen = newSortOrderChosen + ) + } else { + getAlbums( + albumListType = orderType?.mapToAlbumListType() ?: fallbackListType, + size = size, + offset = offset, + append = append, + refresh = refresh or append + ) + } + } + } + + @Suppress("LongParameterList") + private suspend fun fetchAlbumsByGenre( + refresh: Boolean, + append: Boolean, + size: Int, + offset: Int, + selectedGenre: String?, + newSortOrderChosen: Boolean + ) { + if (selectedGenre != null && !newSortOrderChosen) { + getAlbums( + albumListType = AlbumListType.BY_GENRE, + size = size, + offset = offset, + append = append, + refresh = refresh or append, + genre = selectedGenre + ) + return + } + val genres = getGenres(true) + if (genres.isEmpty()) return + genreSelectionRequest.postValue(genres.map { it.name }) + } + suspend fun getAlbumsOfArtist(id: String, name: String?) { withContext(Dispatchers.IO) { val service = musicServiceFactory.getMusicService() @@ -157,8 +232,8 @@ class AlbumListModel( musicService.getGenres(refresh) } - fun sortListByOrder(order: AlbumListType) { - val newList = when (order) { + fun sortListByOrder(order: SortOrder) { + val newList = when (order.mapToAlbumListType()) { AlbumListType.BY_YEAR -> { list.value?.sortedBy { it.year @@ -193,3 +268,20 @@ class AlbumListModel( else -> true } } + +/** + * Maps the user-facing sort order to the Subsonic album list type. + * Lives with the model, which is the sole consumer of the mapping. + */ +private fun SortOrder.mapToAlbumListType(): AlbumListType = when (this) { + SortOrder.RANDOM -> AlbumListType.RANDOM + SortOrder.NEWEST -> AlbumListType.NEWEST + SortOrder.HIGHEST -> AlbumListType.HIGHEST + SortOrder.FREQUENT -> AlbumListType.FREQUENT + SortOrder.RECENT -> AlbumListType.RECENT + SortOrder.BY_NAME -> AlbumListType.SORTED_BY_NAME + SortOrder.BY_ARTIST -> AlbumListType.SORTED_BY_ARTIST + SortOrder.BY_GENRE -> AlbumListType.BY_GENRE + SortOrder.STARRED -> AlbumListType.STARRED + SortOrder.BY_YEAR -> AlbumListType.BY_YEAR +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt index 37568717..1ea1f53e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -55,6 +56,32 @@ open class GenericListModel( open fun showSelectFolderHeader(): Boolean = false + /** + * Unified error handling for coroutines launched by the models. Reports through + * [CommunicationError] and toasts the user. + */ + private val errorHandler = CoroutineExceptionHandler { _, exception -> + communicationError.handleError(exception, context, showToast = true) + } + + /** + * Launches a one-shot operation on the ViewModel's scope with error handling. + * Fragments must not touch viewModelScope directly; they call this (or a dedicated + * model method) so coroutine lifetime stays owned by - and testable with - the ViewModel. + */ + fun launchWithErrorHandler(block: suspend () -> Unit): Job = + viewModelScope.launch(errorHandler) { + block() + } + + /** + * Launches [block] with error handling and the delayed loading indicator, so the + * pull-to-refresh state of the observing fragment is driven through [isRefreshing]. + */ + fun launchWithLoadingIndicator(block: suspend () -> Unit): Job = launchWithErrorHandler { + withDelayedLoadingIndicator { block() } + } + /** * Helper function to check online status */ diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt index f45e055b..b2867c8b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.withContext import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult +import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.service.MusicServiceFactory +import org.moire.ultrasonic.service.PlaybackQueueActions import org.moire.ultrasonic.util.CommunicationError import org.moire.ultrasonic.util.Settings @@ -22,7 +24,8 @@ class SearchListModel( application: Application, activeServerProvider: ActiveServerProvider, musicServiceFactory: MusicServiceFactory, - communicationError: CommunicationError + communicationError: CommunicationError, + private val playbackQueue: PlaybackQueueActions ) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) { var searchResult: MutableLiveData = MutableLiveData() @@ -42,6 +45,16 @@ class SearchListModel( } } + /** + * Enqueue a single song for playback. Runs on the ViewModel's scope so the + * enqueue outlives the fragment that triggered it. + */ + fun playSong(song: Track, append: Boolean) { + launchWithErrorHandler { + playbackQueue.playAppended(song, replaceQueue = !append) + } + } + fun trimResultLength( result: SearchResult, maxArtists: Int = Settings.defaultArtists,