diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 06cb3a205..08c26c098 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -85,6 +85,13 @@ android { } namespace = "com.simplecityapps.shuttle" + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = rootProject.extra["compose_version"] as String + } + dependencies { implementation(fileTree("dir" to "libs", "include" to listOf("*.jar"))) @@ -126,6 +133,27 @@ android { // AppCompat implementation("androidx.appcompat:appcompat:1.6.1") + val composeBom = platform("androidx.compose:compose-bom:2023.10.01") + + implementation(composeBom) + androidTestImplementation(composeBom) + + // Material Design 3 + implementation("androidx.compose.material3:material3") + + // Android Studio Preview support + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // UI Tests + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + // Optional - Integration with activities + implementation("androidx.activity:activity-compose:1.6.1") + // Optional - Integration with LiveData + implementation("androidx.compose.runtime:runtime-livedata") + // Material implementation("com.google.android.material:material:1.10.0") diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt deleted file mode 100644 index 699d7ae3c..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.simplecityapps.shuttle.ui.screens.library.genres - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.TextView -import com.simplecityapps.adapter.ViewBinder -import com.simplecityapps.shuttle.R -import com.simplecityapps.shuttle.model.Genre -import com.simplecityapps.shuttle.ui.common.recyclerview.ViewTypes -import com.squareup.phrase.Phrase - -class GenreBinder(val genre: com.simplecityapps.shuttle.model.Genre, private val listener: Listener) : ViewBinder { - interface Listener { - fun onGenreSelected( - genre: com.simplecityapps.shuttle.model.Genre, - viewHolder: ViewHolder - ) - - fun onOverflowClicked( - view: View, - genre: com.simplecityapps.shuttle.model.Genre - ) {} - } - - override fun createViewHolder(parent: ViewGroup): ViewHolder { - return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item_genre, parent, false)) - } - - override fun viewType(): Int { - return ViewTypes.Genre - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as GenreBinder - - if (genre != other.genre) return false - - return true - } - - override fun hashCode(): Int { - return genre.hashCode() - } - - override fun areContentsTheSame(other: Any): Boolean { - return genre.name == (other as? GenreBinder)?.genre?.name && - genre.songCount == (other as? GenreBinder)?.genre?.songCount - } - - class ViewHolder(itemView: View) : ViewBinder.ViewHolder(itemView) { - private val titleTextView: TextView = itemView.findViewById(R.id.title) - private val subtitleTextView: TextView = itemView.findViewById(R.id.subtitle) - private val overflowButton: ImageButton = itemView.findViewById(R.id.overflowButton) - - init { - itemView.setOnClickListener { viewBinder?.listener?.onGenreSelected(viewBinder!!.genre, this) } - overflowButton.setOnClickListener { viewBinder?.listener?.onOverflowClicked(it, viewBinder!!.genre) } - } - - override fun bind( - viewBinder: GenreBinder, - isPartial: Boolean - ) { - super.bind(viewBinder, isPartial) - - titleTextView.text = viewBinder.genre.name - if (viewBinder.genre.songCount == 0) { - subtitleTextView.text = itemView.resources.getString(R.string.song_list_empty) - } else { - subtitleTextView.text = - Phrase - .fromPlural(itemView.context, R.plurals.songsPlural, viewBinder.genre.songCount) - .put("count", viewBinder.genre.songCount) - .format() - } - } - } -} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt index c8bc2aa96..c2d062f7a 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt @@ -1,27 +1,51 @@ package com.simplecityapps.shuttle.ui.screens.library.genres import android.os.Bundle -import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.widget.PopupMenu +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope +import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.RecyclerView -import com.simplecityapps.adapter.RecyclerAdapter -import com.simplecityapps.adapter.RecyclerListener -import com.simplecityapps.adapter.ViewBinder +import com.simplecityapps.mediaprovider.MediaImporter import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.shuttle.R -import com.simplecityapps.shuttle.ui.common.TagEditorMenuSanitiser +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.MediaProviderType import com.simplecityapps.shuttle.ui.common.autoCleared import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog -import com.simplecityapps.shuttle.ui.common.dialog.showExcludeDialog import com.simplecityapps.shuttle.ui.common.error.userDescription -import com.simplecityapps.shuttle.ui.common.recyclerview.SectionedAdapter import com.simplecityapps.shuttle.ui.common.view.CircularLoadingView import com.simplecityapps.shuttle.ui.common.view.HorizontalLoadingView import com.simplecityapps.shuttle.ui.screens.library.genres.detail.GenreDetailFragmentArgs @@ -36,25 +60,18 @@ import javax.inject.Inject @AndroidEntryPoint class GenreListFragment : Fragment(), - GenreBinder.Listener, - GenreListContract.View, CreatePlaylistDialogFragment.Listener { - private var adapter: RecyclerAdapter by autoCleared() - - private var recyclerView: RecyclerView by autoCleared() + private var composeView: ComposeView by autoCleared() private var circularLoadingView: CircularLoadingView by autoCleared() private var horizontalLoadingView: HorizontalLoadingView by autoCleared() - @Inject - lateinit var presenter: GenreListPresenter + private val viewModel: GenreListViewModel by viewModels() @Inject lateinit var playlistMenuPresenter: PlaylistMenuPresenter private lateinit var playlistMenuView: PlaylistMenuView - private var recyclerViewState: Parcelable? = null - // Lifecycle override fun onCreateView( @@ -73,116 +90,288 @@ class GenreListFragment : playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) - adapter = - object : SectionedAdapter(viewLifecycleOwner.lifecycleScope) { - override fun getSectionName(viewBinder: ViewBinder?): String? { - return (viewBinder as? GenreBinder)?.genre?.let { genre -> - presenter.getFastscrollPrefix(genre) - } - } - } - recyclerView = view.findViewById(R.id.recyclerView) - recyclerView.adapter = adapter - recyclerView.setRecyclerListener(RecyclerListener()) + composeView = view.findViewById(R.id.composeView) + loadGenres() + circularLoadingView = view.findViewById(R.id.circularLoadingView) horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) - savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } - - presenter.bindView(this) playlistMenuPresenter.bindView(playlistMenuView) } - override fun onResume() { - super.onResume() + private fun loadGenres() { + composeView.setContent { + val viewState by viewModel.viewState.collectAsState() - presenter.loadGenres(false) + Genres(viewState) + } } - override fun onPause() { - super.onPause() + @Composable + private fun Genres(viewState: GenreListViewModel.ViewState) { + when (viewState) { + is GenreListViewModel.ViewState.Scanning -> { + setLoadingState(LoadingState.Scanning) + } + + is GenreListViewModel.ViewState.Loading -> { + setLoadingState(LoadingState.Loading) + } + + is GenreListViewModel.ViewState.Ready -> { + val mediaImporterListener = + object : MediaImporter.Listener { + override fun onSongImportProgress( + providerType: MediaProviderType, + message: String, + progress: Progress?, + ) { + setLoadingProgress(progress) + } + } - recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() + if (viewState.genres.isEmpty()) { + if (viewModel.isImportingMedia()) { + viewModel.addMediaImporterListener(mediaImporterListener) + setLoadingState(LoadingState.Scanning) + } else { + viewModel.removeMediaImporterListener(mediaImporterListener) + setLoadingState(LoadingState.Empty) + } + } else { + viewModel.removeMediaImporterListener(mediaImporterListener) + setLoadingState(LoadingState.None) + } + + GenreList(genres = viewState.genres) + } + } } - override fun onSaveInstanceState(outState: Bundle) { - outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState) - super.onSaveInstanceState(outState) + override fun onResume() { + super.onResume() + loadGenres() } override fun onDestroyView() { - presenter.unbindView() playlistMenuPresenter.unbindView() super.onDestroyView() } - // GenreListContract.View Implementation + @Composable + private fun GenreList(genres: List, modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(genres) { genre -> + GenreListItem(genre) + } + } + } - override fun setGenres( - genres: List, - resetPosition: Boolean - ) { - if (resetPosition) { - adapter.clear() + @Composable + private fun GenreListItem(genre: Genre, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier + .weight(1f) + .clickable { onGenreSelected(genre) }, + ) { + Text( + text = genre.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = Phrase + .fromPlural(LocalContext.current, R.plurals.songsPlural, genre.songCount) + .put("count", genre.songCount) + .format() + .toString(), + style = MaterialTheme.typography.bodySmall, + ) + } + GenreMenu(genre) } + } - val data = genres.map { genre -> GenreBinder(genre, this) }.toMutableList() + @Composable + private fun GenreMenu(genre: Genre) { + var isMenuOpened by remember { mutableStateOf(false) } + var isAddToPlaylistSubmenuOpen by remember { mutableStateOf(false) } + + IconButton( + onClick = { isMenuOpened = true }, + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "Genre menu", + ) + DropdownMenu( + expanded = isMenuOpened, + onDismissRequest = { isMenuOpened = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play)) }, + onClick = { + viewModel.play(genre) { result -> + result.onFailure { error -> showLoadError(error as Error) } + } + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_queue)) }, + onClick = { + viewModel.addToQueue(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_playlist)) }, + onClick = { + isMenuOpened = false + isAddToPlaylistSubmenuOpen = true + }, + trailingIcon = { + Icon(Icons.Default.KeyboardArrowRight, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play_next)) }, + onClick = { + viewModel.playNext(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_exclude)) }, + onClick = { + viewModel.exclude(genre) + isMenuOpened = false + }, + ) + + val supportsTagEditing = genre.mediaProviders.all { mediaProvider -> + mediaProvider.supportsTagEditing + } + + if (supportsTagEditing) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_edit_tags)) }, + onClick = { + viewModel.editTags(genre) { result -> + result.onSuccess { songs -> + showTagEditor(songs) + } + } + isMenuOpened = false + }, + ) + } + } + AddToPlaylistSubmenu( + genre = genre, + expanded = isAddToPlaylistSubmenuOpen, + onDismiss = { isAddToPlaylistSubmenuOpen = false }, + ) + } + } - adapter.update(data) { - recyclerViewState?.let { - recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) - recyclerViewState = null + @Composable + private fun AddToPlaylistSubmenu( + genre: Genre, + expanded: Boolean = false, + onDismiss: () -> Unit = {}, + ) { + val playlistData = PlaylistData.Genres(genre) + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.playlist_menu_create_playlist)) }, + onClick = { + CreatePlaylistDialogFragment.newInstance( + playlistData, + context?.getString(R.string.playlist_create_dialog_playlist_name_hint) + ).show(childFragmentManager) + onDismiss() + }, + ) + + for (playlist in playlistMenuPresenter.playlists) { + DropdownMenuItem( + text = { Text(playlist.name) }, + onClick = { + playlistMenuPresenter.addToPlaylist(playlist, playlistData) + onDismiss() + }, + ) } } } - override fun onAddedToQueue(genre: com.simplecityapps.shuttle.model.Genre) { + fun onAddedToQueue(genre: Genre) { Toast.makeText(context, Phrase.from(requireContext(), R.string.queue_item_added).put("item_name", genre.name).format(), Toast.LENGTH_SHORT).show() } - override fun setLoadingState(state: GenreListContract.LoadingState) { + private fun setLoadingState(state: LoadingState) { when (state) { - is GenreListContract.LoadingState.Scanning -> { + is LoadingState.Scanning -> { horizontalLoadingView.setState(HorizontalLoadingView.State.Loading(getString(R.string.library_scan_in_progress))) circularLoadingView.setState(CircularLoadingView.State.None) } - is GenreListContract.LoadingState.Loading -> { + + is LoadingState.Loading -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.Loading(getString(R.string.loading))) } - is GenreListContract.LoadingState.Empty -> { + + is LoadingState.Empty -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.Empty(getString(R.string.genre_list_empty))) } - is GenreListContract.LoadingState.None -> { + + is LoadingState.None -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.None) } } } - override fun setLoadingProgress(progress: Progress?) { + fun setLoadingProgress(progress: Progress?) { progress?.let { horizontalLoadingView.setProgress(progress.asFloat()) } } - override fun showLoadError(error: Error) { + fun showLoadError(error: Error) { Toast.makeText(context, error.userDescription(resources), Toast.LENGTH_LONG).show() } - override fun showTagEditor(songs: List) { + fun showTagEditor(songs: List) { TagEditorAlertDialog.newInstance(songs).show(childFragmentManager) } - // GenreBinder.Listener Implementation - - override fun onGenreSelected( - genre: com.simplecityapps.shuttle.model.Genre, - viewHolder: GenreBinder.ViewHolder - ) { + private fun onGenreSelected(genre: Genre) { if (findNavController().currentDestination?.id != R.id.genreDetailFragment) { findNavController().navigate( R.id.action_libraryFragment_to_genreDetailFragment, @@ -191,50 +380,6 @@ class GenreListFragment : } } - override fun onOverflowClicked( - view: View, - genre: com.simplecityapps.shuttle.model.Genre - ) { - val popupMenu = PopupMenu(requireContext(), view) - popupMenu.inflate(R.menu.menu_popup) - TagEditorMenuSanitiser.sanitise(popupMenu.menu, genre.mediaProviders) - - playlistMenuView.createPlaylistMenu(popupMenu.menu) - - popupMenu.setOnMenuItemClickListener { menuItem -> - if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Genres(genre))) { - return@setOnMenuItemClickListener true - } else { - when (menuItem.itemId) { - R.id.play -> { - presenter.play(genre) - return@setOnMenuItemClickListener true - } - R.id.queue -> { - presenter.addToQueue(genre) - return@setOnMenuItemClickListener true - } - R.id.playNext -> { - presenter.playNext(genre) - return@setOnMenuItemClickListener true - } - R.id.exclude -> { - showExcludeDialog(requireContext(), genre.name) { - presenter.exclude(genre) - } - return@setOnMenuItemClickListener true - } - R.id.editTags -> { - presenter.editTags(genre) - return@setOnMenuItemClickListener true - } - } - } - false - } - popupMenu.show() - } - // CreatePlaylistDialogFragment.Listener Implementation override fun onSave( @@ -249,8 +394,13 @@ class GenreListFragment : companion object { const val TAG = "GenreListFragment" - const val ARG_RECYCLER_STATE = "recycler_state" - fun newInstance() = GenreListFragment() } + + sealed class LoadingState { + object Scanning : LoadingState() + object Loading : LoadingState() + object Empty : LoadingState() + object None : LoadingState() + } } diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt deleted file mode 100644 index 988c22243..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.simplecityapps.shuttle.ui.screens.library.genres - -import com.simplecityapps.mediaprovider.MediaImporter -import com.simplecityapps.mediaprovider.Progress -import com.simplecityapps.mediaprovider.repository.genres.GenreQuery -import com.simplecityapps.mediaprovider.repository.genres.GenreRepository -import com.simplecityapps.mediaprovider.repository.songs.SongRepository -import com.simplecityapps.playback.PlaybackManager -import com.simplecityapps.playback.queue.QueueManager -import com.simplecityapps.shuttle.model.Genre -import com.simplecityapps.shuttle.model.MediaProviderType -import com.simplecityapps.shuttle.query.SongQuery -import com.simplecityapps.shuttle.ui.common.mvp.BasePresenter -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch - -class GenreListContract { - sealed class LoadingState { - object Scanning : LoadingState() - - object Loading : LoadingState() - - object Empty : LoadingState() - - object None : LoadingState() - } - - interface View { - fun setGenres( - genres: List, - resetPosition: Boolean - ) - - fun onAddedToQueue(genre: Genre) - - fun setLoadingState(state: LoadingState) - - fun setLoadingProgress(progress: Progress?) - - fun showLoadError(error: Error) - - fun showTagEditor(songs: List) - } - - interface Presenter { - fun loadGenres(resetPosition: Boolean) - - fun addToQueue(genre: Genre) - - fun playNext(genre: Genre) - - fun exclude(genre: Genre) - - fun editTags(genre: Genre) - - fun play(genre: Genre) - - fun getFastscrollPrefix(genre: Genre): String? - } -} - -class GenreListPresenter -@Inject -constructor( - private val genreRepository: GenreRepository, - private val songRepository: SongRepository, - private val playbackManager: PlaybackManager, - private val mediaImporter: MediaImporter, - private val queueManager: QueueManager -) : GenreListContract.Presenter, - BasePresenter() { - private var genres: List? = null - - private val mediaImporterListener = - object : MediaImporter.Listener { - override fun onSongImportProgress( - providerType: MediaProviderType, - message: String, - progress: Progress? - ) { - view?.setLoadingProgress(progress) - } - } - - override fun unbindView() { - super.unbindView() - - mediaImporter.listeners.remove(mediaImporterListener) - } - - override fun loadGenres(resetPosition: Boolean) { - if (genres == null) { - if (mediaImporter.isImporting) { - view?.setLoadingState(GenreListContract.LoadingState.Scanning) - } else { - view?.setLoadingState(GenreListContract.LoadingState.Loading) - } - } - launch { - genreRepository.getGenres(GenreQuery.All()) - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .collect { genres -> - if (genres.isEmpty()) { - if (mediaImporter.isImporting) { - mediaImporter.listeners.add(mediaImporterListener) - view?.setLoadingState(GenreListContract.LoadingState.Scanning) - } else { - mediaImporter.listeners.remove(mediaImporterListener) - view?.setLoadingState(GenreListContract.LoadingState.Empty) - } - } else { - mediaImporter.listeners.remove(mediaImporterListener) - view?.setLoadingState(GenreListContract.LoadingState.None) - } - this@GenreListPresenter.genres = genres - view?.setGenres(genres, resetPosition) - } - } - } - - override fun addToQueue(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - playbackManager.addToQueue(songs) - view?.onAddedToQueue(genre) - } - } - - override fun playNext(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - playbackManager.playNext(songs) - view?.onAddedToQueue(genre) - } - } - - override fun exclude(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - songRepository.setExcluded(songs, true) - queueManager.remove(queueManager.getQueue().filter { queueItem -> songs.contains(queueItem.song) }) - } - } - - override fun editTags(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - view?.showTagEditor(songs) - } - } - - override fun play(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - if (queueManager.setQueue(songs)) { - playbackManager.load { result -> - result.onSuccess { playbackManager.play() } - result.onFailure { error -> view?.showLoadError(error as Error) } - } - } - } - } - - override fun getFastscrollPrefix(genre: Genre): String? { - return genre.name.first().toString() - } -} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt new file mode 100644 index 000000000..9f25962d9 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt @@ -0,0 +1,115 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simplecityapps.mediaprovider.MediaImporter +import com.simplecityapps.mediaprovider.repository.genres.GenreQuery +import com.simplecityapps.mediaprovider.repository.genres.GenreRepository +import com.simplecityapps.mediaprovider.repository.songs.SongRepository +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.queue.QueueManager +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.query.SongQuery +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GenreListViewModel @Inject constructor( + private val genreRepository: GenreRepository, + private val songRepository: SongRepository, + private val playbackManager: PlaybackManager, + private val mediaImporter: MediaImporter, + private val queueManager: QueueManager, +) : ViewModel() { + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState = _viewState.asStateFlow() + + init { + genreRepository.getGenres(GenreQuery.All()) + .onStart { + if (isImportingMedia()) { + _viewState.emit(ViewState.Scanning) + } else { + _viewState.emit(ViewState.Loading) + } + } + .onEach { genres -> + _viewState.emit(ViewState.Ready(genres)) + } + .launchIn(viewModelScope) + } + + fun play(genre: Genre, completion: (Result) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + if (queueManager.setQueue(songs)) { + playbackManager.load { result -> + result.onSuccess { playbackManager.play() } + completion(result) + } + } + } + } + + fun addToQueue(genre: Genre, completion: (Result) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + playbackManager.addToQueue(songs) + completion(Result.success(genre)) + } + } + + fun playNext(genre: Genre, completion: (Result) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + playbackManager.playNext(songs) + completion(Result.success(genre)) + } + } + + fun exclude(genre: Genre) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + songRepository.setExcluded(songs, true) + queueManager.remove( + queueManager + .getQueue() + .filter { queueItem -> songs.contains(queueItem.song) } + ) + } + } + + fun editTags(genre: Genre, completion: (Result>) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + completion(Result.success(songs)) + } + } + + private suspend fun getSongsForGenreOrEmpty(genre: Genre) = + genreRepository.getSongsForGenre(genre.name, SongQuery.All()) + .firstOrNull() + .orEmpty() + + fun isImportingMedia() = mediaImporter.isImporting + + fun addMediaImporterListener(listener: MediaImporter.Listener) = + mediaImporter.listeners.add(listener) + + fun removeMediaImporterListener(listener: MediaImporter.Listener) = + mediaImporter.listeners.remove(listener) + + sealed class ViewState { + data object Scanning : ViewState() + data object Loading : ViewState() + data class Ready(val genres: List) : ViewState() + } +} diff --git a/android/app/src/main/res/layout/fragment_genres.xml b/android/app/src/main/res/layout/fragment_genres.xml index aa64eb374..f407d4941 100644 --- a/android/app/src/main/res/layout/fragment_genres.xml +++ b/android/app/src/main/res/layout/fragment_genres.xml @@ -7,6 +7,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + +