Merge branch 'cleanviewmodelboundary' into 'develop'

Clean MVC boundary transgressions

See merge request ultrasonic/ultrasonic!1441
This commit is contained in:
birdbird committed 2026-10-04 17:39:43 +00:00
commit 2d49ea0435
9 files changed
+250 -136

No files matched your search

@@ -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()) }
@@ -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<Album>(),
class AlbumListFragment :
EntryListFragment<Album>(),
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"
}
}
@@ -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<List<MusicDirectory.Child>> {
listModel.viewModelScope.launch(
toastingExceptionHandler()
) {
swipeRefresh?.isRefreshing = true
listModel.launchWithLoadingIndicator {
listModel.getBookmarks()
swipeRefresh?.isRefreshing = false
}
return listModel.currentList
}
@@ -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 {
@@ -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))
}
@@ -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<MusicDirectory.Child>(),
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"
}
}
@@ -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<List<Album>> = 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<List<String>> = 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
}
@@ -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
*/
@@ -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<SearchResult?> = 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,