diff --git a/.editorconfig b/.editorconfig index 978f09ade..ede61d03f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,6 @@ ktlint_standard_filename=disabled ktlint_standard_package-name=disabled ktlint_code_style=android_studio ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_trailing-comma-on-declaration-site=disabled +ktlint_standard_trailing-comma-on-call-site=disabled +ktlint_function_naming_ignore_when_annotated_with=Composable diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aa0e1ff97..bee21bef4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -233,6 +233,7 @@ android { // Excludes the support library because it's already included by Glide. isTransitive = false } + implementation(libs.glide.compose) // About Libraries implementation(libs.mikepenz.aboutlibrariesCore) @@ -259,6 +260,9 @@ android { implementation(libs.firebase.crashlytics) // Testing + testImplementation(libs.kotest) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutinesTest) androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.rules) androidTestImplementation(libs.androidx.core.ktx) diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt new file mode 100644 index 000000000..21ad4da56 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt @@ -0,0 +1,60 @@ +package com.simplecityapps.shuttle.ui.common + +import androidx.appcompat.widget.Toolbar +import androidx.core.view.isVisible +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.model.Song +import kotlin.collections.distinct +import kotlin.collections.map +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import timber.log.Timber + +class ComposeContextualToolbarHelper { + + var toolbar: Toolbar? = null + var contextualToolbar: Toolbar? = null + + private val _selectedSongsState = MutableStateFlow(emptySet()) + val selectedSongsState = _selectedSongsState.asStateFlow() + val selectedSongCountState = _selectedSongsState.asStateFlow() + .map { selectedSongs -> selectedSongs.size } + + fun toggleSongSelection(song: Song) { + Timber.d("foo: toggleSongSelection: ${hashCode()}") + _selectedSongsState.value = if (_selectedSongsState.value.contains(song)) { + _selectedSongsState.value - song + } else { + _selectedSongsState.value + song + } + } + + fun clearSelection() { + _selectedSongsState.value = emptySet() + } + + fun isSelecting() = _selectedSongsState.value.isNotEmpty() + + fun show() { + contextualToolbar?.let { contextualToolbar -> + toolbar?.isVisible = false + contextualToolbar.isVisible = true + contextualToolbar.setNavigationOnClickListener { + hide() + } + } ?: Timber.e("Failed to show contextual toolbar: toolbar null") + } + + fun hide() { + toolbar?.isVisible = true + contextualToolbar?.isVisible = false + contextualToolbar?.setNavigationOnClickListener(null) + clearSelection() + } + + fun selectedSongsMediaProviders(): List = selectedSongsState + .value + .map { it.mediaProvider } + .distinct() +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/components/FastScroller.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/components/FastScroller.kt index b838f1aaf..3c06099fa 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/components/FastScroller.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/components/FastScroller.kt @@ -66,9 +66,7 @@ fun FastScroller( thumb: @Composable () -> Unit = { DefaultThumb() }, - popup: @Composable (index: Int) -> Unit = { currentItemIndex -> - DefaultPopup(text = getPopupText(currentItemIndex)) - } + popup: @Composable ((index: Int) -> Unit)? = null ) { val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current @@ -175,6 +173,9 @@ fun FastScroller( thumb() } + val resolvedPopup = popup ?: { currentItemIndex -> + DefaultPopup(text = getPopupText(currentItemIndex)) + } // Position the popup so its bottom aligns with the thumb center. var popupHeight by remember { mutableFloatStateOf(0f) } Box( @@ -193,7 +194,7 @@ fun FastScroller( enter = fadeIn(tween(durationMillis = 150)), exit = fadeOut(tween(durationMillis = 200)) ) { - popup(currentItemIndex) + resolvedPopup(currentItemIndex) } } } @@ -238,7 +239,7 @@ fun DefaultPopup( text?.let { Box( modifier = modifier - .padding(end = 16.dp) + .offset(x = (-16).dp) .sizeIn(minWidth = 64.dp, minHeight = 64.dp) .background( color = MaterialTheme.colorScheme.primary, @@ -248,7 +249,8 @@ fun DefaultPopup( bottomStartPercent = 50, bottomEndPercent = 0 ) - ), + ) + .padding(start = 16.dp, end = 16.dp), contentAlignment = Alignment.Center ) { Text( @@ -261,6 +263,10 @@ fun DefaultPopup( } } +/** Pass this to the popup composable in FastScroller to hide it. */ +@Composable +fun NoPopup(index: Int) = Unit + /** * Data class holding computed thumb scroll state, including an estimated average item height. */ diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/AddToPlaylistSubmenu.kt similarity index 79% rename from android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt rename to android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/AddToPlaylistSubmenu.kt index 469fafe2f..601280baf 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/AddToPlaylistSubmenu.kt @@ -1,4 +1,4 @@ -package com.simplecityapps.shuttle.ui.screens.library.genres +package com.simplecityapps.shuttle.ui.screens.library import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -7,22 +7,22 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.simplecityapps.shuttle.R -import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.model.Playlist import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData import kotlinx.collections.immutable.ImmutableList @Composable -fun AddToPlaylistSubmenu( - genre: Genre, +fun AddToPlaylistSubmenu( + playableItem: T, playlists: ImmutableList, onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + playlistDataCreator: (playableItem: T) -> R, modifier: Modifier = Modifier, expanded: Boolean = false, onDismiss: () -> Unit = {}, - onShowCreatePlaylistDialog: (genre: Genre) -> Unit + onShowCreatePlaylistDialog: (playableItem: T) -> Unit, ) { - val playlistData = PlaylistData.Genres(genre) + val playlistData = playlistDataCreator(playableItem) DropdownMenu( modifier = modifier, @@ -32,7 +32,7 @@ fun AddToPlaylistSubmenu( DropdownMenuItem( text = { Text(stringResource(id = R.string.playlist_menu_create_playlist)) }, onClick = { - onShowCreatePlaylistDialog(genre) + onShowCreatePlaylistDialog(playableItem) onDismiss() } ) 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 0fea14c4d..caab21f70 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 @@ -63,6 +63,7 @@ class GenreListFragment : composeView.setContent { val viewState by viewModel.viewState.collectAsState() + val playlists by playlistMenuPresenter.playlistsState.collectAsState() val theme by viewModel.theme.collectAsStateWithLifecycle() val accent by viewModel.accent.collectAsStateWithLifecycle() @@ -72,7 +73,7 @@ class GenreListFragment : ) { GenreList( viewState = viewState, - playlists = playlistMenuPresenter.playlists.toImmutableList(), + playlists = playlists.toImmutableList(), onSelectGenre = { onGenreSelected(it) }, diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt index fe7684aee..c13ca7563 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.ui.screens.library.AddToPlaylistSubmenu import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData import kotlinx.collections.immutable.ImmutableList @@ -108,11 +109,12 @@ fun GenreMenu( } } AddToPlaylistSubmenu( - genre = genre, + playableItem = genre, expanded = isAddToPlaylistSubmenuOpen, onDismiss = { isAddToPlaylistSubmenuOpen = false }, playlists = playlists, onAddToPlaylist = onAddToPlaylist, + playlistDataCreator = { genre -> PlaylistData.Genres(genre) }, onShowCreatePlaylistDialog = onShowCreatePlaylistDialog ) } diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/ShuffleListItem.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/ShuffleListItem.kt new file mode 100644 index 000000000..efb7cc035 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/ShuffleListItem.kt @@ -0,0 +1,59 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager +import com.simplecityapps.shuttle.ui.theme.AppTheme + +@Composable +fun ShuffleListItem( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .padding(all = 8.dp) + .clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_shuffle_black_24dp), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp), + text = stringResource(com.simplecityapps.shuttle.R.string.btn_shuffle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ShuffleListItemPreview() { + AppTheme( + accent = GeneralPreferenceManager.Accent.Default + ) { + ShuffleListItem() + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt new file mode 100644 index 000000000..12f5c39a5 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt @@ -0,0 +1,215 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.rememberGlidePreloadingData +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.sorting.SongSortOrder +import com.simplecityapps.shuttle.ui.common.components.CircularLoadingState +import com.simplecityapps.shuttle.ui.common.components.FastScroller +import com.simplecityapps.shuttle.ui.common.components.HorizontalLoadingView +import com.simplecityapps.shuttle.ui.common.components.LoadingStatusIndicator +import com.simplecityapps.shuttle.ui.common.components.NoPopup +import com.simplecityapps.shuttle.ui.common.utils.dp as dpToInt +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData +import java.util.Locale +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet + +@Composable +fun SongList( + viewState: SongListViewModel.ViewState, + playlists: ImmutableList, + onSongClick: (Song) -> Unit, + onSongLongClick: (Song) -> Unit, + onAddToQueue: (Song) -> Unit, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + onShowCreatePlaylistDialog: (song: Song) -> Unit, + onPlayNext: (Song) -> Unit, + onSongInfo: (Song) -> Unit, + onExclude: (Song) -> Unit, + onEditTags: (Song) -> Unit, + onDelete: (Song) -> Unit, + onShuffle: () -> Unit, + modifier: Modifier = Modifier, +) { + when (viewState) { + is SongListViewModel.ViewState.Scanning -> { + HorizontalLoadingView( + modifier = modifier + .fillMaxSize() + .wrapContentSize() + .padding(16.dp), + message = stringResource(R.string.library_scan_in_progress), + progress = viewState.progress?.asFloat() ?: 0f + ) + } + + is SongListViewModel.ViewState.Loading -> { + LoadingStatusIndicator( + modifier = modifier + .fillMaxSize() + .wrapContentSize(), + state = CircularLoadingState.Loading(stringResource(R.string.loading)) + ) + } + + is SongListViewModel.ViewState.Ready -> { + if (viewState.songs.isEmpty()) { + LoadingStatusIndicator( + modifier = modifier + .fillMaxSize() + .wrapContentSize() + .padding(16.dp), + state = CircularLoadingState.Empty(stringResource(R.string.song_list_empty)) + ) + } else { + SongList( + songs = viewState.songs.toImmutableList(), + selectedSongs = viewState.selectedSongs.toImmutableSet(), + sortOrder = viewState.sortOrder, + playlists = playlists, + onSongClick = onSongClick, + onSongLongClick = onSongLongClick, + onAddToQueue = onAddToQueue, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, + onPlayNext = onPlayNext, + onSongInfo = onSongInfo, + onExclude = onExclude, + onEditTags = onEditTags, + onDelete = onDelete, + onShuffle = onShuffle, + modifier = modifier, + ) + } + } + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun SongList( + songs: ImmutableList, + selectedSongs: ImmutableSet, + sortOrder: SongSortOrder, + playlists: ImmutableList, + onSongClick: (Song) -> Unit, + onSongLongClick: (Song) -> Unit, + onAddToQueue: (Song) -> Unit, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + onShowCreatePlaylistDialog: (song: Song) -> Unit, + onPlayNext: (Song) -> Unit, + onSongInfo: (Song) -> Unit, + onExclude: (Song) -> Unit, + onEditTags: (Song) -> Unit, + onDelete: (Song) -> Unit, + onShuffle: () -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + + val preloadingData = + rememberGlidePreloadingData( + data = songs, + preloadImageSize = Size(40.dpToInt.toFloat(), 40.dpToInt.toFloat()), + ) { item: Song, requestBuilder: RequestBuilder -> + requestBuilder + .diskCacheStrategy(DiskCacheStrategy.ALL) + .transform(CenterCrop()) + .transform(RoundedCorners(8.dpToInt)) + // Glide ignores this in Compose for now, but not a big deal + .transition(withCrossFade(200)) + .load(item) + } + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .testTag("genres-list-lazy-column"), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp), + state = state, + ) { + item { + ShuffleListItem(onClick = onShuffle) + } + items(preloadingData.size) { index -> + val (song, artworkPreloadRequestBuilder) = preloadingData[index] + + SongListItem( + song = song, + isSelected = selectedSongs.contains(song), + playlists = playlists, + artworkPreloadRequestBuilder = artworkPreloadRequestBuilder, + onClick = onSongClick, + onLongClick = onSongLongClick, + onAddToQueue = onAddToQueue, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, + onPlayNext = onPlayNext, + onSongInfo = onSongInfo, + onExclude = onExclude, + onEditTags = onEditTags, + onDelete = onDelete, + ) + } + } + FastScroller( + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), + state = state, + getPopupText = { index -> + getFastscrollPopupText(songs[index], sortOrder) + }, + popup = getFastscrollPopup(sortOrder) + ) + } +} + +fun getFastscrollPopupText(song: Song, sortOrder: SongSortOrder): String = when (sortOrder) { + SongSortOrder.SongName -> song.name?.firstOrNull()?.toString() + SongSortOrder.ArtistGroupKey -> song.albumArtistGroupKey.key?.firstOrNull()?.toString()?.uppercase(Locale.getDefault()) + SongSortOrder.AlbumGroupKey -> song.albumGroupKey.key?.firstOrNull()?.toString()?.uppercase(Locale.getDefault()) + SongSortOrder.Year -> song.date?.year?.toString() + else -> null +} ?: "" + +fun getFastscrollPopup(sortOrder: SongSortOrder): @Composable ((Int) -> Unit)? = when (sortOrder) { + // Leave the default popup for these cases + SongSortOrder.SongName, + SongSortOrder.ArtistGroupKey, + SongSortOrder.AlbumGroupKey, + SongSortOrder.Year -> null + + // Don't show popup in these cases + SongSortOrder.LastModified, + SongSortOrder.Duration -> ::NoPopup + + // The rest of sort orders aren't available in the menu + else -> null +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt index 2801d0dc4..986c0a82f 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt @@ -1,7 +1,6 @@ package com.simplecityapps.shuttle.ui.screens.library.songs import android.os.Bundle -import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -9,78 +8,51 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.widget.PopupMenu +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView -import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader -import au.com.simplecityapps.shuttle.imageloading.glide.GlideImageLoader -import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader -import com.bumptech.glide.util.ViewPreloadSizeProvider -import com.simplecityapps.adapter.RecyclerAdapter -import com.simplecityapps.adapter.RecyclerListener -import com.simplecityapps.adapter.ViewBinder -import com.simplecityapps.mediaprovider.Progress +import androidx.lifecycle.repeatOnLifecycle import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.sorting.SongSortOrder -import com.simplecityapps.shuttle.ui.common.ContextualToolbarHelper import com.simplecityapps.shuttle.ui.common.TagEditorMenuSanitiser import com.simplecityapps.shuttle.ui.common.autoCleared import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog import com.simplecityapps.shuttle.ui.common.dialog.showDeleteDialog -import com.simplecityapps.shuttle.ui.common.dialog.showExcludeDialog +import com.simplecityapps.shuttle.ui.common.error.UserFriendlyError import com.simplecityapps.shuttle.ui.common.error.userDescription -import com.simplecityapps.shuttle.ui.common.recyclerview.GlidePreloadModelProvider -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.common.view.findToolbarHost -import com.simplecityapps.shuttle.ui.screens.library.albums.ShuffleBinder import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFragment import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuPresenter import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuView import com.simplecityapps.shuttle.ui.screens.songinfo.SongInfoDialogFragment +import com.simplecityapps.shuttle.ui.theme.AppTheme import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch @AndroidEntryPoint class SongListFragment : Fragment(), - SongListContract.View, CreatePlaylistDialogFragment.Listener { - @Inject - lateinit var presenter: SongListPresenter @Inject lateinit var playlistMenuPresenter: PlaylistMenuPresenter - private var adapter: RecyclerAdapter by autoCleared() - - lateinit var imageLoader: GlideImageLoader + private var composeView: ComposeView by autoCleared() - private lateinit var shuffleBinder: ShuffleBinder + private val viewModel: SongListViewModel by viewModels() private lateinit var playlistMenuView: PlaylistMenuView - private var circularLoadingView: CircularLoadingView by autoCleared() - private var horizontalLoadingView: HorizontalLoadingView by autoCleared() - - private var recyclerView: RecyclerView by autoCleared() - - private var recyclerViewState: Parcelable? = null - - private var contextualToolbarHelper: ContextualToolbarHelper by autoCleared() - - private val viewPreloadSizeProvider by lazy { ViewPreloadSizeProvider() } - private val preloadModelProvider by lazy { - GlidePreloadModelProvider( - imageLoader, - listOf(ArtworkImageLoader.Options.CacheDecodedResource) - ) - } - // Lifecycle override fun onCreate(savedInstanceState: Bundle?) { @@ -101,51 +73,122 @@ class SongListFragment : ) { super.onViewCreated(view, savedInstanceState) - imageLoader = GlideImageLoader(this) - setHasOptionsMenu(true) playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) + playlistMenuPresenter.bindView(playlistMenuView) - adapter = - object : SectionedAdapter(viewLifecycleOwner.lifecycleScope) { - override fun getSectionName(viewBinder: ViewBinder?): String = (viewBinder as? SongBinder)?.song?.let { song -> - presenter.getFastscrollPrefix(song) - } ?: "" - } - recyclerView = view.findViewById(R.id.recyclerView) - recyclerView.adapter = adapter - recyclerView.setRecyclerListener(RecyclerListener()) - val preloader: RecyclerViewPreloader = - RecyclerViewPreloader( - imageLoader.requestManager, - preloadModelProvider, - viewPreloadSizeProvider, - 12 - ) - recyclerView.addOnScrollListener(preloader) - - circularLoadingView = view.findViewById(R.id.circularLoadingView) - horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) - - shuffleBinder = - ShuffleBinder( - R.string.btn_shuffle, - object : ShuffleBinder.Listener { - override fun onClicked() { - presenter.shuffle() + composeView = view.findViewById(R.id.composeView) + + updateContextualToolbar() + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.contextualToolbarHelper.selectedSongCountState + .collect { count -> + if (count == 0) { + viewModel.contextualToolbarHelper.hide() + } else { + viewModel.contextualToolbarHelper.show() + + viewModel.contextualToolbarHelper.contextualToolbar?.title = + Phrase.fromPlural(requireContext(), R.plurals.multi_select_items_selected, count) + .put("count", count) + .format() + viewModel.contextualToolbarHelper.contextualToolbar?.menu?.let { menu -> + TagEditorMenuSanitiser.sanitise( + menu, + viewModel.contextualToolbarHelper + .selectedSongsMediaProviders() + ) + } } } - ) + } - savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.selectedSortOrder + .collect { sortOrder -> + updateToolbarMenuSortOrder(sortOrder) + } + } + } - contextualToolbarHelper = ContextualToolbarHelper() + composeView.setContent { + val viewState by viewModel.viewState.collectAsState() + val playlists by playlistMenuPresenter.playlistsState.collectAsState() - updateContextualToolbar() + val theme by viewModel.theme.collectAsStateWithLifecycle() + val accent by viewModel.accent.collectAsStateWithLifecycle() - presenter.bindView(this) - playlistMenuPresenter.bindView(playlistMenuView) + AppTheme( + theme = theme, + accent = accent, + ) { + SongList( + viewState = viewState, + playlists = playlists.toImmutableList(), + onSongClick = { song -> + viewModel.onSongClick(song) { result -> + result.onFailure { error -> + showLoadError(error as Error) + } + } + }, + onSongLongClick = { song -> + viewModel.onSongLongClick(song) + }, + onAddToQueue = { song -> + viewModel.addToQueue(song) { result -> + result.onSuccess { song -> + onAddedToQueue(listOf(song)) + } + } + }, + onAddToPlaylist = { playlist, playlistData -> + playlistMenuPresenter.addToPlaylist(playlist, playlistData) + }, + onShowCreatePlaylistDialog = { song -> + CreatePlaylistDialogFragment.newInstance( + PlaylistData.Songs(song), + context?.getString(R.string.playlist_create_dialog_playlist_name_hint) + ).show(childFragmentManager) + }, + onPlayNext = { song -> + viewModel.playNext(song) { result -> + result.onSuccess { song -> + onAddedToQueue(listOf(song)) + } + } + }, + onSongInfo = { song -> + SongInfoDialogFragment.newInstance(song).show(childFragmentManager) + }, + onExclude = { song -> + viewModel.exclude(song) + }, + onEditTags = { song -> + showTagEditor(song) + }, + onDelete = { song -> + showDeleteDialog(requireContext(), song.name) { + try { + viewModel.delete(song) + } catch (e: UserFriendlyError) { + showDeleteError(e) + } + } + }, + onShuffle = { + viewModel.shuffle { result -> + result.onFailure { error -> + showLoadError(error as Error) + } + } + } + ) + } + } } override fun onCreateOptionsMenu( @@ -155,15 +198,12 @@ class SongListFragment : super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.menu_song_list, menu) - - presenter.updateToolbarMenu() + updateToolbarMenuSortOrder(viewModel.selectedSortOrder.value) } override fun onResume() { super.onResume() - presenter.loadSongs(false) - updateContextualToolbar() } @@ -173,17 +213,9 @@ class SongListFragment : findToolbarHost()?.apply { contextualToolbar?.setOnMenuItemClickListener(null) } - - recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState) - super.onSaveInstanceState(outState) } override fun onDestroyView() { - presenter.unbindView() playlistMenuPresenter.unbindView() super.onDestroyView() @@ -193,27 +225,27 @@ class SongListFragment : override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { R.id.sortSongName -> { - presenter.setSortOrder(SongSortOrder.SongName) + viewModel.setSortOrder(SongSortOrder.SongName) true } R.id.sortArtistName -> { - presenter.setSortOrder(SongSortOrder.ArtistGroupKey) + viewModel.setSortOrder(SongSortOrder.ArtistGroupKey) true } R.id.sortAlbumName -> { - presenter.setSortOrder(SongSortOrder.AlbumGroupKey) + viewModel.setSortOrder(SongSortOrder.AlbumGroupKey) true } R.id.sortSongYear -> { - presenter.setSortOrder(SongSortOrder.Year) + viewModel.setSortOrder(SongSortOrder.Year) true } R.id.sortSongDuration -> { - presenter.setSortOrder(SongSortOrder.Duration) + viewModel.setSortOrder(SongSortOrder.Duration) true } R.id.sortSongDateModified -> { - presenter.setSortOrder(SongSortOrder.LastModified) + viewModel.setSortOrder(SongSortOrder.LastModified) true } else -> false @@ -226,70 +258,42 @@ class SongListFragment : contextualToolbar?.let { contextualToolbar -> contextualToolbar.menu.clear() contextualToolbar.inflateMenu(R.menu.menu_multi_select) - TagEditorMenuSanitiser.sanitise(contextualToolbar.menu, contextualToolbarHelper.selectedItems.map { it.mediaProvider }.distinct()) + TagEditorMenuSanitiser.sanitise( + contextualToolbar.menu, + viewModel.contextualToolbarHelper.selectedSongsMediaProviders(), + ) contextualToolbar.setOnMenuItemClickListener { menuItem -> playlistMenuView.createPlaylistMenu(contextualToolbar.menu) - if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Songs(contextualToolbarHelper.selectedItems.toList()))) { - contextualToolbarHelper.hide() + val selectedSongs = viewModel.contextualToolbarHelper.selectedSongsState.value.toList() + if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Songs(selectedSongs))) { + viewModel.contextualToolbarHelper.hide() return@setOnMenuItemClickListener true } when (menuItem.itemId) { R.id.queue -> { - presenter.addToQueue(contextualToolbarHelper.selectedItems.toList()) - contextualToolbarHelper.hide() + viewModel.addSelectedToQueue() true } R.id.editTags -> { - TagEditorAlertDialog.newInstance(contextualToolbarHelper.selectedItems.toList()).show(childFragmentManager) - contextualToolbarHelper.hide() + TagEditorAlertDialog.newInstance(selectedSongs) + .show(childFragmentManager) + viewModel.contextualToolbarHelper.hide() true } else -> false } } } - contextualToolbarHelper.contextualToolbar = contextualToolbar - contextualToolbarHelper.toolbar = toolbar - contextualToolbarHelper.callback = contextualToolbarCallback - - if (contextualToolbarHelper.selectedItems.isNotEmpty()) { - contextualToolbarHelper.show() - } - } - } - - // SongListContract.View Implementation - - override fun setData( - songs: List, - resetPosition: Boolean - ) { - preloadModelProvider.items = songs - - if (resetPosition) { - adapter.clear() - } - - val data = - songs.map { song -> - SongBinder(song, imageLoader, songBinderListener).apply { - selected = contextualToolbarHelper.selectedItems.any { it.id == song.id } - } - }.toMutableList() - - if (songs.isNotEmpty()) { - data.add(0, shuffleBinder) - } + viewModel.contextualToolbarHelper.contextualToolbar = contextualToolbar + viewModel.contextualToolbarHelper.toolbar = toolbar - adapter.update(data) { - recyclerViewState?.let { - recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) - recyclerViewState = null + if (viewModel.contextualToolbarHelper.isSelecting()) { + viewModel.contextualToolbarHelper.show() } } } - override fun updateToolbarMenuSortOrder(sortOrder: SongSortOrder) { + fun updateToolbarMenuSortOrder(sortOrder: SongSortOrder) { findToolbarHost()?.toolbar?.menu?.let { menu -> when (sortOrder) { SongSortOrder.SongName -> menu.findItem(R.id.sortSongName)?.isChecked = true @@ -305,11 +309,15 @@ class SongListFragment : } } - override fun showLoadError(error: Error) { + fun showLoadError(error: Error) { Toast.makeText(context, error.userDescription(resources), Toast.LENGTH_LONG).show() } - override fun onAddedToQueue(songs: List) { + fun showTagEditor(song: Song) { + TagEditorAlertDialog.newInstance(listOf(song)).show(childFragmentManager) + } + + fun onAddedToQueue(songs: List) { Toast.makeText( context, Phrase.fromPlural(resources, R.plurals.queue_songs_added, songs.size) @@ -319,109 +327,10 @@ class SongListFragment : ).show() } - override fun setLoadingState(state: SongListContract.LoadingState) { - when (state) { - is SongListContract.LoadingState.Scanning -> { - horizontalLoadingView.setState(HorizontalLoadingView.State.Loading(getString(R.string.library_scan_in_progress))) - circularLoadingView.setState(CircularLoadingView.State.None) - } - is SongListContract.LoadingState.Loading -> { - horizontalLoadingView.setState(HorizontalLoadingView.State.None) - circularLoadingView.setState(CircularLoadingView.State.Loading(getString(R.string.loading))) - } - is SongListContract.LoadingState.Empty -> { - horizontalLoadingView.setState(HorizontalLoadingView.State.None) - circularLoadingView.setState(CircularLoadingView.State.Empty(getString(R.string.song_list_empty))) - } - is SongListContract.LoadingState.None -> { - horizontalLoadingView.setState(HorizontalLoadingView.State.None) - circularLoadingView.setState(CircularLoadingView.State.None) - } - } - } - - override fun setLoadingProgress(progress: Progress?) { - progress?.let { - horizontalLoadingView.setProgress(progress.asFloat()) - } - } - - override fun showDeleteError(error: Error) { + fun showDeleteError(error: Error) { Toast.makeText(requireContext(), error.userDescription(resources), Toast.LENGTH_LONG).show() } - // Private - - private val songBinderListener = - object : SongBinder.Listener { - override fun onSongClicked(song: com.simplecityapps.shuttle.model.Song) { - if (!contextualToolbarHelper.handleClick(song)) { - presenter.onSongClicked(song) - } - } - - override fun onSongLongClicked(song: com.simplecityapps.shuttle.model.Song) { - contextualToolbarHelper.handleLongClick(song) - } - - override fun onOverflowClicked( - view: View, - song: com.simplecityapps.shuttle.model.Song - ) { - val popupMenu = PopupMenu(requireContext(), view) - popupMenu.inflate(R.menu.menu_popup_song) - TagEditorMenuSanitiser.sanitise(popupMenu.menu, listOf(song.mediaProvider)) - - playlistMenuView.createPlaylistMenu(popupMenu.menu) - - if (song.externalId != null) { - popupMenu.menu.findItem(R.id.delete)?.isVisible = false - } - - popupMenu.setOnMenuItemClickListener { menuItem -> - if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Songs(song))) { - return@setOnMenuItemClickListener true - } else { - when (menuItem.itemId) { - R.id.queue -> { - presenter.addToQueue(listOf(song)) - return@setOnMenuItemClickListener true - } - R.id.playNext -> { - presenter.playNext(song) - return@setOnMenuItemClickListener true - } - R.id.songInfo -> { - SongInfoDialogFragment.newInstance(song).show(childFragmentManager) - return@setOnMenuItemClickListener true - } - R.id.exclude -> { - showExcludeDialog(requireContext(), song.name) { - presenter.exclude(song) - } - return@setOnMenuItemClickListener true - } - R.id.delete -> { - showDeleteDialog(requireContext(), song.name) { - presenter.delete(song) - } - return@setOnMenuItemClickListener true - } - R.id.editTags -> { - TagEditorAlertDialog.newInstance(listOf(song)).show(childFragmentManager) - } - } - } - false - } - popupMenu.show() - } - - override fun onViewHolderCreated(holder: SongBinder.ViewHolder) { - viewPreloadSizeProvider.setView(holder.imageView) - } - } - // CreatePlaylistDialogFragment.Listener Implementation override fun onSave( @@ -431,34 +340,6 @@ class SongListFragment : playlistMenuPresenter.createPlaylist(text, playlistData) } - // ContextualToolbarHelper.Callback Implementation - - private val contextualToolbarCallback = - object : ContextualToolbarHelper.Callback { - override fun onCountChanged(count: Int) { - contextualToolbarHelper.contextualToolbar?.title = - Phrase.fromPlural(requireContext(), R.plurals.multi_select_items_selected, count) - .put("count", count) - .format() - contextualToolbarHelper.contextualToolbar?.menu?.let { menu -> - TagEditorMenuSanitiser.sanitise(menu, contextualToolbarHelper.selectedItems.map { it.mediaProvider }.distinct()) - } - } - - override fun onItemUpdated( - item: com.simplecityapps.shuttle.model.Song, - isSelected: Boolean - ) { - adapter.items - .filterIsInstance() - .firstOrNull { it.song.id == item.id } - ?.let { viewBinder -> - viewBinder.selected = isSelected - adapter.notifyItemChanged(adapter.items.indexOf(viewBinder)) - } - } - } - // Static companion object { diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt new file mode 100644 index 000000000..4484b6462 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt @@ -0,0 +1,232 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.graphics.drawable.Drawable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.integration.compose.placeholder +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade +import com.simplecityapps.core.R +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager +import com.simplecityapps.shuttle.ui.common.phrase.joinSafely +import com.simplecityapps.shuttle.ui.common.utils.dp as dpToInt +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData +import com.simplecityapps.shuttle.ui.theme.AppTheme +import com.squareup.phrase.ListPhrase +import kotlin.collections.emptyList +import kotlin.time.Instant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.datetime.LocalDate + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalGlideComposeApi::class, +) +@Composable +fun SongListItem( + song: Song, + isSelected: Boolean, + playlists: ImmutableList, + artworkPreloadRequestBuilder: RequestBuilder, + modifier: Modifier = Modifier, + onClick: (Song) -> Unit = {}, + onLongClick: (Song) -> Unit = {}, + onAddToQueue: (Song) -> Unit = {}, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit = { _, _ -> }, + onShowCreatePlaylistDialog: (song: Song) -> Unit = {}, + onPlayNext: (Song) -> Unit = {}, + onSongInfo: (Song) -> Unit = {}, + onExclude: (Song) -> Unit = {}, + onEditTags: (Song) -> Unit = {}, + onDelete: (Song) -> Unit = {}, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + SelectionMark( + isSelected = isSelected, + modifier = Modifier + .width(40.dp) + .height(40.dp), + ) { + GlideImage( + model = song, + contentDescription = stringResource(com.simplecityapps.shuttle.R.string.artwork), + loading = placeholder(R.drawable.ic_placeholder_song_rounded), + ) { + // If this request finishes before than the one from the thumbnail, + // the result of the thumbnail one won't replace it. So, we need to + // repeat all options again here. + // TODO: Find a way to copy options from artworkPreloadRequestBuilder + // to `it`. Maybe wait for the Compose API to stabilize first. + it + .diskCacheStrategy(DiskCacheStrategy.ALL) + .transform(CenterCrop()) + .transform(RoundedCorners(8.dpToInt)) + // Glide ignores this in Compose for now, but not a big deal + .transition(withCrossFade(200)) + .thumbnail(artworkPreloadRequestBuilder) + } + } + Column( + Modifier + .padding(start = 8.dp) + .weight(1f) + .combinedClickable( + onClick = { onClick(song) }, + onLongClick = { onLongClick(song) }, + ), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = song.name ?: "no name", // FIXME + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = ListPhrase + .from(" • ") + .joinSafely( + listOf( + song.friendlyArtistName ?: song.albumArtist, + song.album + ) + )?.toString() ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + ) + } + SongMenu( + song = song, + playlists = playlists, + onAddToQueue = onAddToQueue, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, + onPlayNext = onPlayNext, + onSongInfo = onSongInfo, + onExclude = onExclude, + onEditTags = onEditTags, + onDelete = onDelete, + ) + } +} + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalGlideComposeApi::class, +) +@Composable +private fun SelectionMark( + isSelected: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + content() + + if (isSelected) { + SelectionMarkOverlay() + } + } +} + +@Composable +private fun SelectionMarkOverlay() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0, 0, 0, 112)) + .padding(8.dp), + ) { + Image( + painter = painterResource(com.simplecityapps.shuttle.R.drawable.ic_baseline_check_24), + contentDescription = stringResource(com.simplecityapps.shuttle.R.string.selection_mark), + colorFilter = ColorFilter.tint(Color.White), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun SongListItemPreview() { + AppTheme( + accent = GeneralPreferenceManager.Accent.Default + ) { + SongListItem( + song = Song( + name = "Song name", + albumArtist = "Album artist", + album = "Album name", + id = 1, + artists = emptyList(), + duration = 1, + genres = emptyList(), + path = "/path", + size = 1, + mimeType = "ogg", + playCount = 1, + playbackPosition = 1, + blacklisted = false, + mediaProvider = MediaProviderType.Shuttle, + track = 1, + disc = 1, + date = LocalDate.fromEpochDays(1), + lastModified = Instant.fromEpochSeconds(1), + lastPlayed = Instant.fromEpochSeconds(1), + lastCompleted = Instant.fromEpochSeconds(1), + lyrics = null, + grouping = null, + bitRate = null, + bitDepth = null, + sampleRate = null, + channelCount = null, + ), + isSelected = true, + playlists = emptyList().toImmutableList(), + artworkPreloadRequestBuilder = Glide.with(LocalContext.current).load(null as? String) + ) + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListPresenter.kt deleted file mode 100644 index 454a02725..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListPresenter.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.simplecityapps.shuttle.ui.screens.library.songs - -import android.content.Context -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import com.simplecityapps.mediaprovider.MediaImporter -import com.simplecityapps.mediaprovider.Progress -import com.simplecityapps.mediaprovider.repository.songs.SongRepository -import com.simplecityapps.mediaprovider.repository.songs.comparator -import com.simplecityapps.playback.PlaybackManager -import com.simplecityapps.playback.queue.QueueManager -import com.simplecityapps.shuttle.R -import com.simplecityapps.shuttle.di.AppCoroutineScope -import com.simplecityapps.shuttle.model.MediaProviderType -import com.simplecityapps.shuttle.model.Song -import com.simplecityapps.shuttle.query.SongQuery -import com.simplecityapps.shuttle.sorting.SongSortOrder -import com.simplecityapps.shuttle.ui.common.error.UserFriendlyError -import com.simplecityapps.shuttle.ui.common.mvp.BaseContract -import com.simplecityapps.shuttle.ui.common.mvp.BasePresenter -import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager -import dagger.hilt.android.qualifiers.ApplicationContext -import java.util.* -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -interface SongListContract { - sealed class LoadingState { - object Scanning : LoadingState() - - object Loading : LoadingState() - - object Empty : LoadingState() - - object None : LoadingState() - } - - interface View { - fun setData( - songs: List, - resetPosition: Boolean - ) - - fun updateToolbarMenuSortOrder(sortOrder: SongSortOrder) - - fun showLoadError(error: Error) - - fun onAddedToQueue(songs: List) - - fun setLoadingState(state: LoadingState) - - fun setLoadingProgress(progress: Progress?) - - fun showDeleteError(error: Error) - } - - interface Presenter : BaseContract.Presenter { - fun loadSongs(resetPosition: Boolean) - - fun onSongClicked(song: Song) - - fun addToQueue(songs: List) - - fun playNext(song: Song) - - fun exclude(song: Song) - - fun delete(song: Song) - - fun setSortOrder(songSortOrder: SongSortOrder) - - fun getFastscrollPrefix(song: Song): String? - - fun updateToolbarMenu() - - fun shuffle() - } -} - -class SongListPresenter -@Inject -constructor( - @ApplicationContext private val context: Context, - private val playbackManager: PlaybackManager, - private val songRepository: SongRepository, - private val mediaImporter: MediaImporter, - private val sortPreferenceManager: SortPreferenceManager, - private val queueManager: QueueManager, - @AppCoroutineScope private val appCoroutineScope: CoroutineScope -) : BasePresenter(), - SongListContract.Presenter { - var songs: List = emptyList() - - private val mediaImporterListener = - object : MediaImporter.Listener { - override fun onSongImportProgress( - providerType: MediaProviderType, - message: String, - progress: Progress? - ) { - view?.setLoadingProgress(progress) - } - } - - override fun bindView(view: SongListContract.View) { - super.bindView(view) - - view.updateToolbarMenuSortOrder(sortPreferenceManager.sortOrderSongList) - } - - override fun unbindView() { - super.unbindView() - - mediaImporter.listeners.remove(mediaImporterListener) - } - - override fun loadSongs(resetPosition: Boolean) { - if (songs.isEmpty()) { - if (mediaImporter.isImporting) { - view?.setLoadingState(SongListContract.LoadingState.Scanning) - } else { - view?.setLoadingState(SongListContract.LoadingState.Loading) - } - } - launch { - songRepository - .getSongs(SongQuery.All(sortOrder = sortPreferenceManager.sortOrderSongList)) - .filterNotNull() - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .collect { songs -> - this@SongListPresenter.songs = songs - if (songs.isEmpty()) { - if (mediaImporter.isImporting) { - mediaImporter.listeners.add(mediaImporterListener) - view?.setLoadingState(SongListContract.LoadingState.Scanning) - } else { - mediaImporter.listeners.remove(mediaImporterListener) - view?.setLoadingState(SongListContract.LoadingState.Empty) - } - } else { - mediaImporter.listeners.remove(mediaImporterListener) - view?.setLoadingState(SongListContract.LoadingState.None) - } - view?.setData(songs, resetPosition) - } - } - } - - override fun onSongClicked(song: Song) { - launch { - if (queueManager.setQueue(songs = songs, position = songs.indexOf(song))) { - playbackManager.load { result -> - result.onSuccess { - playbackManager.play() - } - result.onFailure { error -> - view?.showLoadError(error as Error) - } - } - } - } - } - - override fun addToQueue(songs: List) { - launch { - playbackManager.addToQueue(songs) - view?.onAddedToQueue(songs) - } - } - - override fun playNext(song: Song) { - launch { - playbackManager.playNext(listOf(song)) - view?.onAddedToQueue(listOf(song)) - } - } - - override fun exclude(song: Song) { - launch { - songRepository.setExcluded(listOf(song), true) - queueManager.remove(queueManager.getQueue().filter { queueItem -> songs.contains(queueItem.song) }) - } - } - - override fun delete(song: Song) { - val uri = song.path.toUri() - val documentFile = DocumentFile.fromSingleUri(context, uri) - if (documentFile?.delete() == true) { - launch { - songRepository.remove(song) - queueManager.remove(queueManager.getQueue().filter { it.song.id == song.id }) - } - } else { - view?.showDeleteError(UserFriendlyError(context.getString(R.string.delete_song_failed))) - } - } - - override fun setSortOrder(songSortOrder: SongSortOrder) { - if (sortPreferenceManager.sortOrderSongList != songSortOrder) { - launch { - withContext(Dispatchers.IO) { - sortPreferenceManager.sortOrderSongList = songSortOrder - this@SongListPresenter.songs = songs.sortedWith(songSortOrder.comparator) - } - view?.setData(songs, true) - view?.updateToolbarMenuSortOrder(songSortOrder) - } - } - } - - override fun getFastscrollPrefix(song: Song): String? = when (sortPreferenceManager.sortOrderSongList) { - SongSortOrder.SongName -> song.name?.firstOrNull()?.toString() - SongSortOrder.ArtistGroupKey -> song.albumArtistGroupKey.key?.firstOrNull()?.toString()?.uppercase(Locale.getDefault()) - SongSortOrder.AlbumGroupKey -> song.albumGroupKey.key?.firstOrNull()?.toString()?.uppercase(Locale.getDefault()) - SongSortOrder.Year -> song.date?.year?.toString() - else -> null - } - - override fun shuffle() { - if (songs.isEmpty()) { - view?.showLoadError(UserFriendlyError("Your library is empty")) - return - } - - appCoroutineScope.launch { - playbackManager.shuffle(songs) { result -> - result.onSuccess { playbackManager.play() } - result.onFailure { error -> view?.showLoadError(Error(error)) } - } - } - } - - override fun updateToolbarMenu() { - view?.updateToolbarMenuSortOrder(sortPreferenceManager.sortOrderSongList) - } -} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt new file mode 100644 index 000000000..7058b4a93 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt @@ -0,0 +1,192 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import android.app.Application +import androidx.annotation.OpenForTesting +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.simplecityapps.mediaprovider.MediaImportObserver +import com.simplecityapps.mediaprovider.Progress +import com.simplecityapps.mediaprovider.SongImportState +import com.simplecityapps.mediaprovider.repository.songs.SongRepository +import com.simplecityapps.mediaprovider.repository.songs.comparator +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.queue.QueueManager +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager +import com.simplecityapps.shuttle.query.SongQuery +import com.simplecityapps.shuttle.sorting.SongSortOrder +import com.simplecityapps.shuttle.ui.common.ComposeContextualToolbarHelper +import com.simplecityapps.shuttle.ui.common.error.UserFriendlyError +import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OpenForTesting +@HiltViewModel +class SongListViewModel @Inject constructor( + private val songRepository: SongRepository, + private val playbackManager: PlaybackManager, + private val queueManager: QueueManager, + private val sortPreferenceManager: SortPreferenceManager, + preferenceManager: GeneralPreferenceManager, + mediaImportObserver: MediaImportObserver, + application: Application, +) : AndroidViewModel(application) { + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState = _viewState.asStateFlow() + + private val _selectedSortOrder = MutableStateFlow(sortPreferenceManager.sortOrderSongList) + val selectedSortOrder = _selectedSortOrder.asStateFlow() + + val contextualToolbarHelper = ComposeContextualToolbarHelper() + + val theme = preferenceManager.theme(viewModelScope) + val accent = preferenceManager.accent(viewModelScope) + + init { + combine( + songRepository + .getSongs(SongQuery.All(sortOrder = sortPreferenceManager.sortOrderSongList)) + .filterNotNull(), + mediaImportObserver.songImportState, + contextualToolbarHelper.selectedSongsState, + _selectedSortOrder, + ) { songs, songImportState, selectedSongs, __selectedSortOrder -> + if (songImportState is SongImportState.ImportProgress) { + _viewState.emit(ViewState.Scanning(songImportState.progress)) + } else { + val sortedSongs = songs.sortedWith(__selectedSortOrder.comparator) + _viewState.emit(ViewState.Ready(sortedSongs, selectedSongs, __selectedSortOrder)) + } + } + .onStart { + _viewState.emit(ViewState.Loading) + } + .launchIn(viewModelScope) + } + + fun onSongClick(song: Song, completion: (Result) -> Unit) { + if (contextualToolbarHelper.isSelecting()) { + contextualToolbarHelper.toggleSongSelection(song) + completion(Result.success(true)) + } else { + play(song, completion) + } + } + + fun onSongLongClick(song: Song) { + contextualToolbarHelper.toggleSongSelection(song) + } + + private fun play(song: Song, completion: (Result) -> Unit) { + viewModelScope.launch { + val songs = viewState.value.let { + if (it is ViewState.Ready) it.songs else listOf(song) + } + + if (queueManager.setQueue(songs = songs, position = songs.indexOf(song))) { + playbackManager.load { result -> + result.onSuccess { playbackManager.play() } + completion(result) + } + } + } + } + + fun addToQueue(song: Song, completion: (Result) -> Unit) { + viewModelScope.launch { + playbackManager.addToQueue(listOf(song)) + completion(Result.success(song)) + } + } + + fun addSelectedToQueue() { + viewModelScope.launch { + playbackManager.addToQueue(contextualToolbarHelper.selectedSongsState.value.toList()) + contextualToolbarHelper.clearSelection() + } + } + + fun playNext(song: Song, completion: (Result) -> Unit) { + viewModelScope.launch { + playbackManager.playNext(listOf(song)) + completion(Result.success(song)) + } + } + + fun exclude(song: Song) { + viewModelScope.launch { + songRepository.setExcluded(listOf(song), true) + queueManager.remove(song) + } + } + + fun delete(song: Song) { + val context = getApplication().applicationContext + val documentFile = DocumentFile.fromSingleUri(context, song.path.toUri()) + + if (documentFile?.delete() == false) { + throw UserFriendlyError(context.getString(R.string.delete_song_failed)) + } + + viewModelScope.launch { + songRepository.remove(song) + queueManager.remove(song) + } + } + + fun shuffle(completion: (Result) -> Unit) { + val songs = getSongs() + + if (songs.isEmpty()) { + completion(Result.failure(UserFriendlyError("Your library is empty"))) + return + } + + viewModelScope.launch { + playbackManager.shuffle(songs) { result -> + result.onSuccess { playbackManager.play() } + completion(result) + } + } + } + + private fun getSongs(): List = viewState.value.let { + if (it is ViewState.Ready) it.songs else emptyList() + } + + fun setSortOrder(sortOrder: SongSortOrder) { + if (sortPreferenceManager.sortOrderSongList == sortOrder) { + return + } + + viewModelScope.launch { + withContext(Dispatchers.IO) { + sortPreferenceManager.sortOrderSongList = sortOrder + _selectedSortOrder.value = sortOrder + } + } + } + + sealed class ViewState { + data class Scanning(val progress: Progress?) : ViewState() + data object Loading : ViewState() + data class Ready( + val songs: List, + val selectedSongs: Set, + val sortOrder: SongSortOrder, + ) : ViewState() + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongMenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongMenu.kt new file mode 100644 index 000000000..96d9f5160 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongMenu.kt @@ -0,0 +1,128 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.ui.screens.library.AddToPlaylistSubmenu +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SongMenu( + song: Song, + playlists: ImmutableList, + onAddToQueue: (Song) -> Unit, + onPlayNext: (Song) -> Unit, + onSongInfo: (Song) -> Unit, + onExclude: (Song) -> Unit, + onEditTags: (Song) -> Unit, + onDelete: (Song) -> Unit, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + onShowCreatePlaylistDialog: (song: Song) -> Unit, + modifier: Modifier = Modifier, +) { + var isMenuOpened by remember { mutableStateOf(false) } + var isAddToPlaylistSubmenuOpen by remember { mutableStateOf(false) } + + IconButton( + modifier = modifier, + onClick = { isMenuOpened = true } + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.song_context_menu), + tint = MaterialTheme.colorScheme.onBackground + ) + DropdownMenu( + expanded = isMenuOpened, + onDismissRequest = { isMenuOpened = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_queue)) }, + onClick = { + onAddToQueue(song) + isMenuOpened = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_playlist)) }, + onClick = { + isMenuOpened = false + isAddToPlaylistSubmenuOpen = true + }, + trailingIcon = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play_next)) }, + onClick = { + onPlayNext(song) + isMenuOpened = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_song_info)) }, + onClick = { + onSongInfo(song) + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_exclude)) }, + onClick = { + onExclude(song) + isMenuOpened = false + }, + ) + + if (song.mediaProvider.supportsTagEditing) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_edit_tags)) }, + onClick = { + onEditTags(song) + isMenuOpened = false + }, + ) + } + + if (song.canBeDeleted()) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_delete)) }, + onClick = { + onDelete(song) + isMenuOpened = false + } + ) + } + } + AddToPlaylistSubmenu( + playableItem = song, + expanded = isAddToPlaylistSubmenuOpen, + onDismiss = { isAddToPlaylistSubmenuOpen = false }, + playlists = playlists, + onAddToPlaylist = onAddToPlaylist, + playlistDataCreator = { song -> PlaylistData.Songs(song) }, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog + ) + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/playlistmenu/PlaylistMenuPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/playlistmenu/PlaylistMenuPresenter.kt index df0585e2b..da2150117 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/playlistmenu/PlaylistMenuPresenter.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/playlistmenu/PlaylistMenuPresenter.kt @@ -18,7 +18,8 @@ import com.simplecityapps.shuttle.ui.common.mvp.BaseContract import com.simplecityapps.shuttle.ui.common.mvp.BasePresenter import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import timber.log.Timber @@ -76,6 +77,8 @@ constructor( ) : BasePresenter(), PlaylistMenuContract.Presenter { override var playlists: List = emptyList() + private val _playlistsState = MutableStateFlow(emptyList()) + val playlistsState = _playlistsState.asStateFlow() override fun bindView(view: PlaylistMenuContract.View) { super.bindView(view) @@ -88,6 +91,7 @@ constructor( playlistRepository.getPlaylists(PlaylistQuery.All(mediaProviderType = null)) .collect { playlists -> this@PlaylistMenuPresenter.playlists = playlists + this@PlaylistMenuPresenter._playlistsState.value = playlists } } } diff --git a/android/app/src/main/res/layout/fragment_songs.xml b/android/app/src/main/res/layout/fragment_songs.xml index 8735c89a0..02da06da1 100644 --- a/android/app/src/main/res/layout/fragment_songs.xml +++ b/android/app/src/main/res/layout/fragment_songs.xml @@ -6,16 +6,10 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + android:layout_height="match_parent" /> No library tabs visible + + Song context menu + + Artwork + + Selection mark diff --git a/android/app/src/test/java/com/simplecityapps/creationFunctions.kt b/android/app/src/test/java/com/simplecityapps/creationFunctions.kt new file mode 100644 index 000000000..cc51e1358 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/creationFunctions.kt @@ -0,0 +1,50 @@ +package com.simplecityapps + +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.model.Song +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate + +fun createSong( + id: Long = 1, + name: String = "song-name", + albumArtist: String = "album-artist", + album: String = "album-name", + track: Int = 1, + duration: Int = 1, + date: LocalDate = LocalDate(2024, 2, 11), + playCount: Int = 0, + lastPlayed: Instant? = Instant.fromEpochSeconds(1), + lastCompleted: Instant? = Instant.fromEpochSeconds(1), + mediaProvider: MediaProviderType = MediaProviderType.Shuttle, +) = Song( + id = id, + name = name, + albumArtist = albumArtist, + artists = emptyList(), + album = album, + track = track, + disc = 1, + duration = duration, + date = date, + genres = emptyList(), + path = "/path/to/song", + size = 1, + mimeType = "ogg", + lastModified = Instant.fromEpochSeconds(1), + lastPlayed = lastPlayed, + lastCompleted = lastCompleted, + playCount = playCount, + playbackPosition = 1, + blacklisted = false, + externalId = null, + mediaProvider = mediaProvider, + replayGainTrack = null, + replayGainAlbum = null, + lyrics = null, + grouping = null, + bitRate = null, + bitDepth = null, + sampleRate = null, + channelCount = null, +) diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt new file mode 100644 index 000000000..9c6a5519e --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt @@ -0,0 +1,403 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import android.app.Application +import com.simplecityapps.createSong +import com.simplecityapps.mediaprovider.MediaImportObserver +import com.simplecityapps.mediaprovider.Progress +import com.simplecityapps.mediaprovider.SongImportState +import com.simplecityapps.mediaprovider.repository.songs.SongRepository +import com.simplecityapps.neverEmittingFlow +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.queue.QueueManager +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager +import com.simplecityapps.shuttle.sorting.SongSortOrder +import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.equality.shouldBeEqualUsingFields +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Test + +@ExperimentalCoroutinesApi +class SongListViewModelTest { + val mockSongRepository: SongRepository = mockk() + val mockPlaybackManager: PlaybackManager = mockk() + val mockQueueManager: QueueManager = mockk() + val mockSortPreferenceManager: SortPreferenceManager = mockk() + val mockPreferenceManager: GeneralPreferenceManager = mockk(relaxed = true) + val mockMediaImportObserver: MediaImportObserver = mockk() + val mockApplication: Application = mockk() + + lateinit var viewModel: SongListViewModel + + val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + mockSortOrderPreference(SongSortOrder.Default) + mockSongs(emptyList()) + mockSongImportState(SongImportState.Idle) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `viewState initially emits Loading while songs are loading`() = runTest { + // Arrange + every { mockSongRepository.getSongs(any()) } returns + neverEmittingFlow() + + // Act: Initialize the ViewModel. The `init` block will execute. + viewModel = createViewModel() + + // Assert: before the flows are consumed + viewModel.viewState.value shouldBe SongListViewModel.ViewState.Loading + + // Act: Wait for the flows + advanceUntilIdle() + + // Assert: after the flows are consumed + viewModel.viewState.value shouldBe SongListViewModel.ViewState.Loading + } + + @Test + fun `viewState emits Scanning while media importer is scanning songs`() = runTest { + mockSongs(emptyList()) + mockSongImportStateAsImportProgress(IMPORT_PROGRESS) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.viewState.value shouldBe + SongListViewModel.ViewState.Scanning(IMPORT_PROGRESS) + } + + @Test + fun `viewState emits Ready with the list of songs, empty selection and sort order`() = runTest { + mockSongs(listOf(SONG)) + mockSortOrderPreference(SongSortOrder.Default) + mockSongImportStateAsImportComplete() + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.viewState.value shouldBe + SongListViewModel.ViewState.Ready( + listOf(SONG), + emptySet(), + SongSortOrder.Default + ) + } + + @Test + fun `plays song when clicked and adds all songs to the queue`() = runTest { + val songs = listOf(SONG1, SONG2) + mockSongs(songs) + mockSongImportStateAsImportComplete() + coEvery { mockQueueManager.setQueue(allAny()) } returns + true + // FIXME: This is too fragile. Implement PlaybackManager.loadAndPlay? + coEvery { mockPlaybackManager.load(seekPosition = null, completion = any()) } answers { + (arg(1) as (Result) -> Unit).invoke(Result.success(true)) + } + every { mockPlaybackManager.play() } just Runs + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onSongClick(SONG2) {} + advanceUntilIdle() + + coVerify(exactly = 1) { + mockQueueManager.setQueue(songs = songs, position = 1) + mockPlaybackManager.load(completion = any()) + mockPlaybackManager.play() + } + } + + @Test + fun `plays song when clicked and just adds it to the queue when import hasn't finished`() = runTest { + val songs = listOf(SONG1, SONG2) + mockSongs(songs) + mockSongImportStateAsImportProgress() + coEvery { mockQueueManager.setQueue(allAny()) } returns + true + // FIXME: This is too fragile. Implement PlaybackManager.loadAndPlay? + coEvery { mockPlaybackManager.load(seekPosition = null, completion = any()) } answers { + (arg(1) as (Result) -> Unit).invoke(Result.success(true)) + } + every { mockPlaybackManager.play() } just Runs + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onSongClick(SONG2) {} + advanceUntilIdle() + + coVerify(exactly = 1) { + mockQueueManager.setQueue(songs = listOf(SONG2), position = 0) + mockPlaybackManager.load(completion = any()) + mockPlaybackManager.play() + } + } + + @Test + fun `starts selection on song long click`() = runTest { + mockSongs(listOf(SONG)) + viewModel = createViewModel() + advanceUntilIdle() + viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() + + viewModel.onSongLongClick(SONG) + + viewModel.contextualToolbarHelper.isSelecting().shouldBeTrue() + viewModel.contextualToolbarHelper.selectedSongsState.value + .shouldBe(listOf(SONG)) + } + + @Test + fun `adds song to selection when clicking it in selection mode`() = runTest { + val songs = listOf(SONG1, SONG2) + mockSongs(songs) + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onSongLongClick(SONG1) + + viewModel.onSongClick(SONG2) {} + + viewModel.contextualToolbarHelper.isSelecting().shouldBeTrue() + viewModel.contextualToolbarHelper.selectedSongsState.value + .shouldBe(songs) + } + + @Test + fun `removes selected song from selection`() = runTest { + val songs = listOf(SONG1, SONG2) + mockSongs(songs) + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onSongLongClick(SONG1) + viewModel.onSongClick(SONG2) {} + + viewModel.onSongClick(SONG1) {} + + viewModel.contextualToolbarHelper.isSelecting().shouldBeTrue() + viewModel.contextualToolbarHelper.selectedSongsState.value + .shouldBe(listOf(SONG2)) + } + + @Test + fun `exists selection mode when last selected song is clicked`() = runTest { + mockSongs(listOf(SONG)) + viewModel = createViewModel() + advanceUntilIdle() + viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() + viewModel.onSongLongClick(SONG) + + viewModel.onSongClick(SONG) {} + + viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() + viewModel.contextualToolbarHelper.selectedSongsState.value + .shouldBe(emptyList()) + } + + @Test + fun `adds song to queue`() = runTest { + mockSongs(listOf(SONG)) + coEvery { mockPlaybackManager.addToQueue(allAny()) } just Runs + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.addToQueue(SONG) {} + advanceUntilIdle() + + coVerify(exactly = 1) { mockPlaybackManager.addToQueue(listOf(SONG)) } + } + + @Test + fun `adds selected songs to queue`() = runTest { + val songs = listOf(SONG1, SONG2) + mockSongs(songs) + coEvery { mockPlaybackManager.addToQueue(allAny()) } just Runs + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onSongLongClick(SONG2) + + viewModel.addSelectedToQueue() + advanceUntilIdle() + + coVerify(exactly = 1) { mockPlaybackManager.addToQueue(listOf(SONG2)) } + viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() + } + + @Test + fun `adds a song to play next`() = runTest { + mockSongs(listOf(SONG)) + coEvery { mockPlaybackManager.playNext(allAny()) } just Runs + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.playNext(SONG) {} + advanceUntilIdle() + + coVerify(exactly = 1) { mockPlaybackManager.playNext(listOf(SONG)) } + } + + @Test + fun `shuffles songs`() = runTest { + mockSongs(listOf(SONG)) + // FIXME: This is too fragile. Implement PlaybackManager.shuffleAndPlay? + coEvery { mockPlaybackManager.shuffle(songs = any(), completion = any()) } answers { + (arg(1) as (Result) -> Unit).invoke(Result.success(true)) + } + every { mockPlaybackManager.play() } just Runs + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.shuffle {} + advanceUntilIdle() + + coVerify(exactly = 1) { + mockPlaybackManager.shuffle(songs = listOf(SONG), any()) + mockPlaybackManager.play() + } + } + + @Test + fun `fails to shuffle when there aren't any songs`() = runTest { + var shuffleFailure = false + mockSongs(emptyList()) + // FIXME: This is too fragile. Implement PlaybackManager.shuffleAndPlay? + coEvery { mockPlaybackManager.shuffle(songs = any(), completion = any()) } answers { + (arg(1) as (Result) -> Unit).invoke(Result.success(true)) + } + every { mockPlaybackManager.play() } just Runs + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.shuffle { shuffleFailure = true } + advanceUntilIdle() + + shuffleFailure.shouldBeTrue() + coVerify(exactly = 0) { + mockPlaybackManager.shuffle(songs = any(), any()) + mockPlaybackManager.play() + } + } + + @Test + fun `emits songs sorted in the sort order from the preferences`() = runTest { + val song2 = createSong(name = "2") + val song1 = createSong(name = "1") + val songs = listOf(song2, song1) + mockSongs(songs) + mockSortOrderPreference(SongSortOrder.SongName) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.selectedSortOrder.value.shouldBe(SongSortOrder.SongName) + viewModel.viewState.value shouldBeEqualUsingFields + SongListViewModel.ViewState.Ready( + songs.reversed(), + emptySet(), + SongSortOrder.SongName, + ) + } + + @Test + @Ignore( + """Fails due to running in IO dispatcher. Fix by injecting + StandardTestDispatcher when creating the view model.""" + ) + fun `sets the sort order`() = runTest { + val spiedSortPreferenceManager = spyk( + SortPreferenceManager(mockk(relaxed = true)) + ) + viewModel = SongListViewModel( + songRepository = mockSongRepository, + playbackManager = mockPlaybackManager, + queueManager = mockQueueManager, + sortPreferenceManager = spiedSortPreferenceManager, + preferenceManager = mockPreferenceManager, + mediaImportObserver = mockMediaImportObserver, + application = mockApplication, + ) + advanceUntilIdle() + + viewModel.setSortOrder(SongSortOrder.ArtistGroupKey) + advanceUntilIdle() + + coVerify(exactly = 1) { + spiedSortPreferenceManager.sortOrderSongList = SongSortOrder.ArtistGroupKey + } + viewModel.selectedSortOrder.value.shouldBe(SongSortOrder.ArtistGroupKey) + } + + fun createViewModel(): SongListViewModel = SongListViewModel( + songRepository = mockSongRepository, + playbackManager = mockPlaybackManager, + queueManager = mockQueueManager, + sortPreferenceManager = mockSortPreferenceManager, + preferenceManager = mockPreferenceManager, + mediaImportObserver = mockMediaImportObserver, + application = mockApplication, + ) + + fun mockSongs(songs: List) = every { mockSongRepository.getSongs(any()) } returns + flowOf(songs) + + fun mockSongImportStateAsImportComplete() { + mockSongImportState( + SongImportState.ImportComplete( + MediaProviderType.Shuttle, + null, + ) + ) + } + + private fun mockSongImportStateAsImportProgress(importProgress: Progress? = null) { + mockSongImportState( + SongImportState.ImportProgress( + MediaProviderType.Shuttle, + null, + importProgress, + ) + ) + } + + fun mockSongImportState(state: SongImportState) = every { mockMediaImportObserver.songImportState } returns + MutableStateFlow(state) + + fun mockSortOrderPreference(sortOrder: SongSortOrder) = every { mockSortPreferenceManager.sortOrderSongList } returns + sortOrder +} + +val IMPORT_PROGRESS = Progress(0, 0) +val SONG = createSong() +val SONG1 = createSong(id = 1) +val SONG2 = createSong(id = 2) diff --git a/android/app/src/test/java/com/simplecityapps/utils.kt b/android/app/src/test/java/com/simplecityapps/utils.kt new file mode 100644 index 000000000..2d69afec4 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/utils.kt @@ -0,0 +1,6 @@ +package com.simplecityapps + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +fun neverEmittingFlow(): Flow = flowOf() diff --git a/android/data/src/main/kotlin/com/simplecityapps/shuttle/model/Song.kt b/android/data/src/main/kotlin/com/simplecityapps/shuttle/model/Song.kt index c5b14c2f5..438aeeb7c 100644 --- a/android/data/src/main/kotlin/com/simplecityapps/shuttle/model/Song.kt +++ b/android/data/src/main/kotlin/com/simplecityapps/shuttle/model/Song.kt @@ -84,4 +84,6 @@ data class Song( null } } + + fun canBeDeleted(): Boolean = externalId == null } diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt index ec56d1adf..05d0cc07a 100644 --- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt @@ -29,6 +29,7 @@ abstract class SongDataDao { abstract fun getAllSongData(): Flow> fun getAll(): Flow> = getAllSongData().map { list -> + Timber.i("getAll() ${list.size}") list.map { songData -> songData.toSong() } diff --git a/android/playback/src/main/java/com/simplecityapps/playback/queue/QueueManager.kt b/android/playback/src/main/java/com/simplecityapps/playback/queue/QueueManager.kt index ac9f963e2..389043405 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/queue/QueueManager.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/queue/QueueManager.kt @@ -172,6 +172,11 @@ class QueueManager( } } + fun remove(song: Song) { + val songQueueItem = getQueue().filter { it.song.id == song.id } + remove(songQueueItem) + } + fun clear() { Timber.v("clear()") queue.clear() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5942098b4..a94899702 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ firebase-crashlytics = "3.0.6" fluent-system-icons = "1.1.311" fragment-ktx = "1.8.9" glide = "5.0.5" +glide-compose = "1.0.0-beta08" google-services = "4.4.4" hamcrest-library = "3.0" hilt-android = "2.57.2" @@ -33,10 +34,12 @@ hilt-compiler = "1.3.0" hilt-work = "1.3.0" junit = "4.13.2" junit-version = "1.3.0" +kotest = "6.0.7" kotlin = "2.2.21" kotlinx-coroutines-android = "1.10.2" kotlinx-coroutines-core = "1.10.2" kotlinx-coroutines-play-services = "1.10.2" +kotlinx-coroutines-test = "1.10.2" kotlinx-datetime = "0.7.1" ktaglib = "1.6.1" leakcanary-android = "2.14" @@ -47,6 +50,7 @@ lifecycle-viewmodel-compose = "2.9.4" logging-interceptor = "5.3.0" material = "1.13.0" media = "1.7.1" +mockk = "1.14.7" moshi = "1.15.2" moshi-adapters = "1.15.2" moshi-kotlin = "1.15.2" @@ -134,6 +138,7 @@ glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } glide-annotations = { module = "com.github.bumptech.glide:annotations", version.ref = "glide" } glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } glide-okhttp3Integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "okhttp3-integration" } +glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glide-compose" } google-material = { module = "com.google.android.material:material", version.ref = "material" } google-play-services-cast-framework = { module = "com.google.android.gms:play-services-cast-framework", version.ref = "play-services-cast-framework" } google-review = { module = "com.google.android.play:review", version.ref = "review" } @@ -144,11 +149,13 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hil hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" } jaredrummler-androidDeviceNames = { module = "com.jaredrummler:android-device-names", version.ref = "android-device-names" } junit = { module = "junit:junit", version.ref = "junit" } +kotest = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlinx-coroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } kotlinx-coroutinesPlayServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines-play-services" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref ="kotlinx-coroutines-test"} kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary-android" } mikepenz-aboutlibrariesCore = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } @@ -156,6 +163,7 @@ moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi-adapters" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi-kotlin" } moshi-kotlinCodegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi-kotlin-codegen" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } nanohttpd-webserver = { module = "org.nanohttpd:nanohttpd-webserver", version.ref = "nanohttpd-webserver" } okhttp3-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "logging-interceptor" } okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }