From 455bcf1efc62a9498b9f5633412b938cc6c30ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 23 Nov 2024 13:44:10 +0100 Subject: [PATCH 01/31] Disable KtLint rules about trailing commas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They are useful. From Kotlin coding conventions (*): Using trailing commas has several benefits: - It makes version-control diffs cleaner – as all the focus is on the changed value. - It makes it easy to add and reorder elements – there is no need to add or delete the comma if you manipulate elements. - It simplifies code generation, for example, for object initializers. The last element can also have a comma. Trailing commas are entirely optional – your code will still work without them. The Kotlin style guide encourages the use of trailing commas at the declaration site and leaves it at your discretion for the call site. (*) https://kotlinlang .org/docs/coding-conventions.html#trailing-commas --- .editorconfig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.editorconfig b/.editorconfig index 978f09ade..87a5333cd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,5 @@ 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 From a17249fecdfb8699758b947c1ee0b80d1730d885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 23 Nov 2024 19:14:03 +0100 Subject: [PATCH 02/31] Stop Ktlint from complaining about function names when annotated with @Compose --- .editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/.editorconfig b/.editorconfig index 87a5333cd..ede61d03f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,3 +11,4 @@ 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 From c24ae3501f8cb87ed8353c87027ec99698b5ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Tue, 26 Aug 2025 20:07:26 +0200 Subject: [PATCH 03/31] Fix genre context menu having no playlists on "Add to playlist" submenu This happened only from time to time, after switching to other tabs or pages. The menu would appear only with the "New playlist" entry. It would fix itself after switching to other tabs (not always). The issue was the playlist list passed to the genre list composable wasn't using a reactive type, so it wouldn't trigger a recomposition if it changed. I guess there's some race condition that make it work most of the time. --- .../shuttle/ui/screens/library/genres/GenreListFragment.kt | 3 ++- .../shuttle/ui/screens/playlistmenu/PlaylistMenuPresenter.kt | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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/playlistmenu/PlaylistMenuPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/playlistmenu/PlaylistMenuPresenter.kt index df0585e2b..7ab2a0ff8 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 @@ -17,6 +17,8 @@ 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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.firstOrNull @@ -76,6 +78,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 +92,7 @@ constructor( playlistRepository.getPlaylists(PlaylistQuery.All(mediaProviderType = null)) .collect { playlists -> this@PlaylistMenuPresenter.playlists = playlists + this@PlaylistMenuPresenter._playlistsState.value = playlists } } } From 2f1ceed806c1522c48a5c8c4b2677db3ba50ad18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Mon, 24 Feb 2025 20:14:22 +0100 Subject: [PATCH 04/31] Implement minimal song list --- .../ui/screens/library/songs/SongList.kt | 100 +++++++++++++++++ .../screens/library/songs/SongListFragment.kt | 31 ++++-- .../ui/screens/library/songs/SongListItem.kt | 102 ++++++++++++++++++ .../library/songs/SongListViewModel.kt | 56 ++++++++++ .../src/main/res/layout/fragment_songs.xml | 12 +-- 5 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt 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..3092f184b --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt @@ -0,0 +1,100 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +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.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Song +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 + +@Composable +fun SongList( + viewState: SongListViewModel.ViewState, + 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, + + ) + } + } + } +} + +@Composable +private fun SongList( + songs: List, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + + 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, + ) { + items(songs) { song -> + SongListItem( + song = song, + ) + } + } + FastScroller( + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), + state = state, + getPopupText = { index -> + (songs)[index].name?.firstOrNull()?.toString() ?: "" // FIXME + }, + ) + } +} 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..289a0ef4f 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 @@ -10,15 +9,15 @@ 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.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.viewModels 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 com.simplecityapps.shuttle.R @@ -31,7 +30,6 @@ import com.simplecityapps.shuttle.ui.common.dialog.showDeleteDialog import com.simplecityapps.shuttle.ui.common.dialog.showExcludeDialog 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 @@ -56,6 +54,9 @@ class SongListFragment : @Inject lateinit var playlistMenuPresenter: PlaylistMenuPresenter + private var composeView: ComposeView by autoCleared() + + private val viewModel: SongListViewModel by viewModels() private var adapter: RecyclerAdapter by autoCleared() lateinit var imageLoader: GlideImageLoader @@ -67,7 +68,7 @@ class SongListFragment : private var circularLoadingView: CircularLoadingView by autoCleared() private var horizontalLoadingView: HorizontalLoadingView by autoCleared() - private var recyclerView: RecyclerView by autoCleared() + // private var recyclerView: RecyclerView by autoCleared() private var recyclerViewState: Parcelable? = null @@ -107,6 +108,17 @@ class SongListFragment : playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) + composeView = view.findViewById(R.id.composeView) + + composeView.setContent { + val viewState by viewModel.viewState.collectAsState() + + SongList( + viewState = viewState, + ) + } + +/* adapter = object : SectionedAdapter(viewLifecycleOwner.lifecycleScope) { override fun getSectionName(viewBinder: ViewBinder?): String = (viewBinder as? SongBinder)?.song?.let { song -> @@ -124,6 +136,7 @@ class SongListFragment : 12 ) recyclerView.addOnScrollListener(preloader) +*/ circularLoadingView = view.findViewById(R.id.circularLoadingView) horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) @@ -174,7 +187,7 @@ class SongListFragment : contextualToolbar?.setOnMenuItemClickListener(null) } - recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() + // recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() } override fun onSaveInstanceState(outState: Bundle) { @@ -282,10 +295,12 @@ class SongListFragment : } adapter.update(data) { +/* recyclerViewState?.let { recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) recyclerViewState = null } +*/ } } 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..4034ad7dc --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt @@ -0,0 +1,102 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +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.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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.simplecityapps.core.R +import com.simplecityapps.shuttle.model.MediaProviderType +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.theme.AppTheme +import com.squareup.phrase.ListPhrase +import kotlin.time.Instant +import kotlinx.datetime.LocalDate + + +@Composable +fun SongListItem( + song: Song, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier + .padding(start = 8.dp) + .weight(1f), + ) { + 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, + ) + } + } +} + +@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, + ), + ) + } +} 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..5bd3b3dcd --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt @@ -0,0 +1,56 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import androidx.annotation.OpenForTesting +import androidx.lifecycle.ViewModel +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.shuttle.model.Song +import com.simplecityapps.shuttle.query.SongQuery +import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +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 + +@OpenForTesting +@HiltViewModel +class SongListViewModel @Inject constructor( + private val songRepository: SongRepository, + private val sortPreferenceManager: SortPreferenceManager, + mediaImportObserver: MediaImportObserver +) : ViewModel() { + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState = _viewState.asStateFlow() + + init { + combine( + songRepository + .getSongs(SongQuery.All(sortOrder = sortPreferenceManager.sortOrderSongList)) + .filterNotNull(), + mediaImportObserver.songImportState, + ) { songs, songImportState -> + if (songImportState is SongImportState.ImportProgress) { + _viewState.emit(ViewState.Scanning(songImportState.progress)) + } else { + _viewState.emit(ViewState.Ready(songs)) + } + } + .onStart { + _viewState.emit(ViewState.Loading) + } + .launchIn(viewModelScope) + } + + sealed class ViewState { + data class Scanning(val progress: Progress?) : ViewState() + data object Loading : ViewState() + data class Ready(val songs: List) : ViewState() + } +} 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" /> Date: Tue, 18 Mar 2025 21:31:23 +0100 Subject: [PATCH 05/31] Add song context menu with "Add to queue" entry --- .../ui/screens/library/songs/SongList.kt | 5 +- .../screens/library/songs/SongListFragment.kt | 9 +++- .../ui/screens/library/songs/SongListItem.kt | 6 ++- .../library/songs/SongListViewModel.kt | 10 ++++ .../ui/screens/library/songs/SongMenu.kt | 54 +++++++++++++++++++ .../src/main/res/values/strings_library.xml | 2 + 6 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongMenu.kt 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 index 3092f184b..4aff25db6 100644 --- 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 @@ -26,6 +26,7 @@ import com.simplecityapps.shuttle.ui.common.components.LoadingStatusIndicator fun SongList( viewState: SongListViewModel.ViewState, modifier: Modifier = Modifier, + onAddToQueue: (Song) -> Unit = {}, ) { when (viewState) { is SongListViewModel.ViewState.Scanning -> { @@ -60,7 +61,7 @@ fun SongList( } else { SongList( songs = viewState.songs, - + onAddToQueue = onAddToQueue, ) } } @@ -71,6 +72,7 @@ fun SongList( private fun SongList( songs: List, modifier: Modifier = Modifier, + onAddToQueue: (Song) -> Unit = {}, ) { val state = rememberLazyListState() @@ -86,6 +88,7 @@ private fun SongList( items(songs) { song -> SongListItem( song = song, + onAddToQueue = onAddToQueue, ) } } 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 289a0ef4f..61c7da2bc 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 @@ -114,7 +114,14 @@ class SongListFragment : val viewState by viewModel.viewState.collectAsState() SongList( - viewState = viewState, + viewState = viewState, + onAddToQueue = { song -> + viewModel.addToQueue(song) { result -> + result.onSuccess { song -> + onAddedToQueue(listOf(song)) + } + } + } ) } 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 index 4034ad7dc..bd6559134 100644 --- 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 @@ -23,11 +23,11 @@ import com.squareup.phrase.ListPhrase import kotlin.time.Instant import kotlinx.datetime.LocalDate - @Composable fun SongListItem( song: Song, modifier: Modifier = Modifier, + onAddToQueue: (Song) -> Unit = {}, ) { Row( modifier = modifier, @@ -58,6 +58,10 @@ fun SongListItem( color = MaterialTheme.colorScheme.onBackground, ) } + SongMenu( + song = song, + onAddToQueue = onAddToQueue, + ) } } 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 index 5bd3b3dcd..bd94ea090 100644 --- 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 @@ -7,6 +7,7 @@ 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.playback.PlaybackManager import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.query.SongQuery import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager @@ -18,11 +19,13 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch @OpenForTesting @HiltViewModel class SongListViewModel @Inject constructor( private val songRepository: SongRepository, + private val playbackManager: PlaybackManager, private val sortPreferenceManager: SortPreferenceManager, mediaImportObserver: MediaImportObserver ) : ViewModel() { @@ -48,6 +51,13 @@ class SongListViewModel @Inject constructor( .launchIn(viewModelScope) } + fun addToQueue(song: Song, completion: (Result) -> Unit) { + viewModelScope.launch { + playbackManager.addToQueue(listOf(song)) + completion(Result.success(song)) + } + } + sealed class ViewState { data class Scanning(val progress: Progress?) : ViewState() data object Loading : 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..9c31df1f8 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongMenu.kt @@ -0,0 +1,54 @@ +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.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.Song + +@Composable +fun SongMenu( + song: Song, + onAddToQueue: (Song) -> Unit, + modifier: Modifier = Modifier, +) { + var isMenuOpened 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 + } + ) + } + } +} diff --git a/android/app/src/main/res/values/strings_library.xml b/android/app/src/main/res/values/strings_library.xml index dfc02b6d5..2ed93083f 100644 --- a/android/app/src/main/res/values/strings_library.xml +++ b/android/app/src/main/res/values/strings_library.xml @@ -28,4 +28,6 @@ No library tabs visible + + Song context menu From ff6dc70b50daf611e85c3ea2e82c8e6d1e746bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 23 Mar 2025 20:15:08 +0100 Subject: [PATCH 06/31] Add "Add play next" context menu entry --- .../shuttle/ui/screens/library/songs/SongList.kt | 4 ++++ .../shuttle/ui/screens/library/songs/SongListFragment.kt | 7 +++++++ .../shuttle/ui/screens/library/songs/SongListItem.kt | 2 ++ .../shuttle/ui/screens/library/songs/SongListViewModel.kt | 7 +++++++ .../shuttle/ui/screens/library/songs/SongMenu.kt | 8 ++++++++ 5 files changed, 28 insertions(+) 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 index 4aff25db6..0d695f4b8 100644 --- 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 @@ -27,6 +27,7 @@ fun SongList( viewState: SongListViewModel.ViewState, modifier: Modifier = Modifier, onAddToQueue: (Song) -> Unit = {}, + onPlayNext: (Song) -> Unit, ) { when (viewState) { is SongListViewModel.ViewState.Scanning -> { @@ -62,6 +63,7 @@ fun SongList( SongList( songs = viewState.songs, onAddToQueue = onAddToQueue, + onPlayNext = onPlayNext, ) } } @@ -73,6 +75,7 @@ private fun SongList( songs: List, modifier: Modifier = Modifier, onAddToQueue: (Song) -> Unit = {}, + onPlayNext: (Song) -> Unit = {}, ) { val state = rememberLazyListState() @@ -89,6 +92,7 @@ private fun SongList( SongListItem( song = song, onAddToQueue = onAddToQueue, + onPlayNext = onPlayNext, ) } } 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 61c7da2bc..6ab5fd8d7 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 @@ -121,6 +121,13 @@ class SongListFragment : onAddedToQueue(listOf(song)) } } + }, + onPlayNext = { song -> + viewModel.playNext(song) { result -> + result.onSuccess { song -> + onAddedToQueue(listOf(song)) + } + } } ) } 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 index bd6559134..4c02a1c07 100644 --- 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 @@ -28,6 +28,7 @@ fun SongListItem( song: Song, modifier: Modifier = Modifier, onAddToQueue: (Song) -> Unit = {}, + onPlayNext: (Song) -> Unit = {}, ) { Row( modifier = modifier, @@ -61,6 +62,7 @@ fun SongListItem( SongMenu( song = song, onAddToQueue = onAddToQueue, + onPlayNext = onPlayNext, ) } } 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 index bd94ea090..a48c3e4b6 100644 --- 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 @@ -58,6 +58,13 @@ class SongListViewModel @Inject constructor( } } + fun playNext(song: Song, completion: (Result) -> Unit) { + viewModelScope.launch { + playbackManager.playNext(listOf(song)) + completion(Result.success(song)) + } + } + sealed class ViewState { data class Scanning(val progress: Progress?) : ViewState() data object Loading : 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 index 9c31df1f8..26c931c77 100644 --- 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 @@ -24,6 +24,7 @@ import com.simplecityapps.shuttle.model.Song fun SongMenu( song: Song, onAddToQueue: (Song) -> Unit, + onPlayNext: (Song) -> Unit, modifier: Modifier = Modifier, ) { var isMenuOpened by remember { mutableStateOf(false) } @@ -49,6 +50,13 @@ fun SongMenu( isMenuOpened = false } ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play_next)) }, + onClick = { + onPlayNext(song) + isMenuOpened = false + } + ) } } } From 8d8770230456b9c8a154b6431323f4c1f5c2ed49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 23 Mar 2025 20:34:38 +0100 Subject: [PATCH 07/31] Add "Song info" context menu entry --- .../shuttle/ui/screens/library/songs/SongList.kt | 6 +++++- .../shuttle/ui/screens/library/songs/SongListFragment.kt | 5 ++++- .../shuttle/ui/screens/library/songs/SongListItem.kt | 2 ++ .../shuttle/ui/screens/library/songs/SongMenu.kt | 8 ++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) 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 index 0d695f4b8..887df6a09 100644 --- 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 @@ -27,7 +27,8 @@ fun SongList( viewState: SongListViewModel.ViewState, modifier: Modifier = Modifier, onAddToQueue: (Song) -> Unit = {}, - onPlayNext: (Song) -> Unit, + onPlayNext: (Song) -> Unit = {}, + onSongInfo: (Song) -> Unit = {}, ) { when (viewState) { is SongListViewModel.ViewState.Scanning -> { @@ -64,6 +65,7 @@ fun SongList( songs = viewState.songs, onAddToQueue = onAddToQueue, onPlayNext = onPlayNext, + onSongInfo = onSongInfo, ) } } @@ -76,6 +78,7 @@ private fun SongList( modifier: Modifier = Modifier, onAddToQueue: (Song) -> Unit = {}, onPlayNext: (Song) -> Unit = {}, + onSongInfo: (Song) -> Unit = {}, ) { val state = rememberLazyListState() @@ -93,6 +96,7 @@ private fun SongList( song = song, onAddToQueue = onAddToQueue, onPlayNext = onPlayNext, + onSongInfo = onSongInfo, ) } } 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 6ab5fd8d7..28fe4f1d2 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 @@ -128,7 +128,10 @@ class SongListFragment : onAddedToQueue(listOf(song)) } } - } + }, + onSongInfo = { song -> + SongInfoDialogFragment.newInstance(song).show(childFragmentManager) + }, ) } 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 index 4c02a1c07..924195db6 100644 --- 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 @@ -29,6 +29,7 @@ fun SongListItem( modifier: Modifier = Modifier, onAddToQueue: (Song) -> Unit = {}, onPlayNext: (Song) -> Unit = {}, + onSongInfo: (Song) -> Unit = {}, ) { Row( modifier = modifier, @@ -63,6 +64,7 @@ fun SongListItem( song = song, onAddToQueue = onAddToQueue, onPlayNext = onPlayNext, + onSongInfo = onSongInfo, ) } } 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 index 26c931c77..379245d82 100644 --- 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 @@ -25,6 +25,7 @@ fun SongMenu( song: Song, onAddToQueue: (Song) -> Unit, onPlayNext: (Song) -> Unit, + onSongInfo: (Song) -> Unit, modifier: Modifier = Modifier, ) { var isMenuOpened by remember { mutableStateOf(false) } @@ -57,6 +58,13 @@ fun SongMenu( isMenuOpened = false } ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_song_info)) }, + onClick = { + onSongInfo(song) + isMenuOpened = false + }, + ) } } } From ec65e6f30b3ff8ed1e5f205758e2f5f111111d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 23 Mar 2025 20:46:10 +0100 Subject: [PATCH 08/31] Add "Exclude" context menu entry --- .../shuttle/ui/screens/library/songs/SongList.kt | 4 ++++ .../ui/screens/library/songs/SongListFragment.kt | 3 +++ .../ui/screens/library/songs/SongListItem.kt | 2 ++ .../ui/screens/library/songs/SongListViewModel.kt | 13 +++++++++++++ .../shuttle/ui/screens/library/songs/SongMenu.kt | 8 ++++++++ 5 files changed, 30 insertions(+) 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 index 887df6a09..a0b2e3e4e 100644 --- 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 @@ -29,6 +29,7 @@ fun SongList( onAddToQueue: (Song) -> Unit = {}, onPlayNext: (Song) -> Unit = {}, onSongInfo: (Song) -> Unit = {}, + onExclude: (Song) -> Unit = {}, ) { when (viewState) { is SongListViewModel.ViewState.Scanning -> { @@ -66,6 +67,7 @@ fun SongList( onAddToQueue = onAddToQueue, onPlayNext = onPlayNext, onSongInfo = onSongInfo, + onExclude = onExclude, ) } } @@ -79,6 +81,7 @@ private fun SongList( onAddToQueue: (Song) -> Unit = {}, onPlayNext: (Song) -> Unit = {}, onSongInfo: (Song) -> Unit = {}, + onExclude: (Song) -> Unit = {}, ) { val state = rememberLazyListState() @@ -97,6 +100,7 @@ private fun SongList( onAddToQueue = onAddToQueue, onPlayNext = onPlayNext, onSongInfo = onSongInfo, + onExclude = onExclude, ) } } 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 28fe4f1d2..bb26b4b5f 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 @@ -132,6 +132,9 @@ class SongListFragment : onSongInfo = { song -> SongInfoDialogFragment.newInstance(song).show(childFragmentManager) }, + onExclude = { song -> + viewModel.exclude(song) + }, ) } 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 index 924195db6..582d22d5a 100644 --- 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 @@ -30,6 +30,7 @@ fun SongListItem( onAddToQueue: (Song) -> Unit = {}, onPlayNext: (Song) -> Unit = {}, onSongInfo: (Song) -> Unit = {}, + onExclude: (Song) -> Unit = {}, ) { Row( modifier = modifier, @@ -65,6 +66,7 @@ fun SongListItem( onAddToQueue = onAddToQueue, onPlayNext = onPlayNext, onSongInfo = onSongInfo, + onExclude = onExclude, ) } } 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 index a48c3e4b6..10543dd42 100644 --- 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 @@ -8,6 +8,7 @@ import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.mediaprovider.SongImportState import com.simplecityapps.mediaprovider.repository.songs.SongRepository import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.queue.QueueManager import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.query.SongQuery import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager @@ -26,6 +27,7 @@ import kotlinx.coroutines.launch class SongListViewModel @Inject constructor( private val songRepository: SongRepository, private val playbackManager: PlaybackManager, + private val queueManager: QueueManager, private val sortPreferenceManager: SortPreferenceManager, mediaImportObserver: MediaImportObserver ) : ViewModel() { @@ -65,6 +67,17 @@ class SongListViewModel @Inject constructor( } } + fun exclude(song: Song) { + viewModelScope.launch { + songRepository.setExcluded(listOf(song), true) + queueManager.remove( + queueManager + .getQueue() + .filter { queueItem -> song == queueItem.song }, + ) + } + } + sealed class ViewState { data class Scanning(val progress: Progress?) : ViewState() data object Loading : 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 index 379245d82..6bda3a710 100644 --- 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 @@ -26,6 +26,7 @@ fun SongMenu( onAddToQueue: (Song) -> Unit, onPlayNext: (Song) -> Unit, onSongInfo: (Song) -> Unit, + onExclude: (Song) -> Unit, modifier: Modifier = Modifier, ) { var isMenuOpened by remember { mutableStateOf(false) } @@ -65,6 +66,13 @@ fun SongMenu( isMenuOpened = false }, ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_exclude)) }, + onClick = { + onExclude(song) + isMenuOpened = false + }, + ) } } } From 010481985312b4d76172b57cd50bd33ef49a9bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 23 Mar 2025 20:58:09 +0100 Subject: [PATCH 09/31] Add "Edit tags" context menu entry --- .../shuttle/ui/screens/library/songs/SongList.kt | 4 ++++ .../ui/screens/library/songs/SongListFragment.kt | 8 ++++++++ .../shuttle/ui/screens/library/songs/SongListItem.kt | 2 ++ .../shuttle/ui/screens/library/songs/SongMenu.kt | 11 +++++++++++ 4 files changed, 25 insertions(+) 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 index a0b2e3e4e..ebbfe704c 100644 --- 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 @@ -30,6 +30,7 @@ fun SongList( onPlayNext: (Song) -> Unit = {}, onSongInfo: (Song) -> Unit = {}, onExclude: (Song) -> Unit = {}, + onEditTags: (Song) -> Unit = {}, ) { when (viewState) { is SongListViewModel.ViewState.Scanning -> { @@ -68,6 +69,7 @@ fun SongList( onPlayNext = onPlayNext, onSongInfo = onSongInfo, onExclude = onExclude, + onEditTags = onEditTags, ) } } @@ -82,6 +84,7 @@ private fun SongList( onPlayNext: (Song) -> Unit = {}, onSongInfo: (Song) -> Unit = {}, onExclude: (Song) -> Unit = {}, + onEditTags: (Song) -> Unit = {}, ) { val state = rememberLazyListState() @@ -101,6 +104,7 @@ private fun SongList( onPlayNext = onPlayNext, onSongInfo = onSongInfo, onExclude = onExclude, + onEditTags = onEditTags, ) } } 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 bb26b4b5f..b55f74393 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 @@ -21,6 +21,7 @@ import com.simplecityapps.adapter.RecyclerAdapter import com.simplecityapps.adapter.ViewBinder import com.simplecityapps.mediaprovider.Progress 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 @@ -135,6 +136,9 @@ class SongListFragment : onExclude = { song -> viewModel.exclude(song) }, + onEditTags = { song -> + showTagEditor(song) + }, ) } @@ -344,6 +348,10 @@ class SongListFragment : Toast.makeText(context, error.userDescription(resources), Toast.LENGTH_LONG).show() } + fun showTagEditor(song: Song) { + TagEditorAlertDialog.newInstance(listOf(song)).show(childFragmentManager) + } + override fun onAddedToQueue(songs: List) { Toast.makeText( context, 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 index 582d22d5a..61380ba84 100644 --- 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 @@ -31,6 +31,7 @@ fun SongListItem( onPlayNext: (Song) -> Unit = {}, onSongInfo: (Song) -> Unit = {}, onExclude: (Song) -> Unit = {}, + onEditTags: (Song) -> Unit = {}, ) { Row( modifier = modifier, @@ -67,6 +68,7 @@ fun SongListItem( onPlayNext = onPlayNext, onSongInfo = onSongInfo, onExclude = onExclude, + onEditTags = onEditTags, ) } } 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 index 6bda3a710..c56f03e6b 100644 --- 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 @@ -27,6 +27,7 @@ fun SongMenu( onPlayNext: (Song) -> Unit, onSongInfo: (Song) -> Unit, onExclude: (Song) -> Unit, + onEditTags: (Song) -> Unit, modifier: Modifier = Modifier, ) { var isMenuOpened by remember { mutableStateOf(false) } @@ -73,6 +74,16 @@ fun SongMenu( isMenuOpened = false }, ) + + if (song.mediaProvider.supportsTagEditing) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_edit_tags)) }, + onClick = { + onEditTags(song) + isMenuOpened = false + }, + ) + } } } } From c709df7c93fd620acabe086af3d1e523d83c5879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 23 Mar 2025 21:06:22 +0100 Subject: [PATCH 10/31] Add "Delete" context menu entry --- .../ui/screens/library/songs/SongList.kt | 4 +++ .../screens/library/songs/SongListFragment.kt | 10 +++++++ .../ui/screens/library/songs/SongListItem.kt | 2 ++ .../library/songs/SongListViewModel.kt | 27 ++++++++++++++++--- .../ui/screens/library/songs/SongMenu.kt | 11 ++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) 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 index ebbfe704c..6c03008e0 100644 --- 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 @@ -31,6 +31,7 @@ fun SongList( onSongInfo: (Song) -> Unit = {}, onExclude: (Song) -> Unit = {}, onEditTags: (Song) -> Unit = {}, + onDelete: (Song) -> Unit = {}, ) { when (viewState) { is SongListViewModel.ViewState.Scanning -> { @@ -70,6 +71,7 @@ fun SongList( onSongInfo = onSongInfo, onExclude = onExclude, onEditTags = onEditTags, + onDelete = onDelete, ) } } @@ -85,6 +87,7 @@ private fun SongList( onSongInfo: (Song) -> Unit = {}, onExclude: (Song) -> Unit = {}, onEditTags: (Song) -> Unit = {}, + onDelete: (Song) -> Unit = {}, ) { val state = rememberLazyListState() @@ -105,6 +108,7 @@ private fun SongList( onSongInfo = onSongInfo, onExclude = onExclude, onEditTags = onEditTags, + onDelete = onDelete, ) } } 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 b55f74393..a156cbf38 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 @@ -29,6 +29,7 @@ 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.view.CircularLoadingView @@ -139,6 +140,15 @@ class SongListFragment : onEditTags = { song -> showTagEditor(song) }, + onDelete = { song -> + showDeleteDialog(requireContext(), song.name) { + try { + viewModel.delete(song) + } catch (e: UserFriendlyError) { + showDeleteError(e) + } + } + }, ) } 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 index 61380ba84..fc976689c 100644 --- 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 @@ -32,6 +32,7 @@ fun SongListItem( onSongInfo: (Song) -> Unit = {}, onExclude: (Song) -> Unit = {}, onEditTags: (Song) -> Unit = {}, + onDelete: (Song) -> Unit = {}, ) { Row( modifier = modifier, @@ -69,6 +70,7 @@ fun SongListItem( onSongInfo = onSongInfo, onExclude = onExclude, onEditTags = onEditTags, + onDelete = onDelete, ) } } 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 index 10543dd42..4578a3a9a 100644 --- 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 @@ -1,7 +1,10 @@ package com.simplecityapps.shuttle.ui.screens.library.songs +import android.app.Application import androidx.annotation.OpenForTesting -import androidx.lifecycle.ViewModel +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 @@ -9,8 +12,10 @@ import com.simplecityapps.mediaprovider.SongImportState import com.simplecityapps.mediaprovider.repository.songs.SongRepository 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.query.SongQuery +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 @@ -29,8 +34,9 @@ class SongListViewModel @Inject constructor( private val playbackManager: PlaybackManager, private val queueManager: QueueManager, private val sortPreferenceManager: SortPreferenceManager, - mediaImportObserver: MediaImportObserver -) : ViewModel() { + mediaImportObserver: MediaImportObserver, + application: Application, +) : AndroidViewModel(application) { private val _viewState = MutableStateFlow(ViewState.Loading) val viewState = _viewState.asStateFlow() @@ -78,6 +84,21 @@ class SongListViewModel @Inject constructor( } } + 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) + val songQueueItem = queueManager.getQueue().filter { it.song.id == song.id } + queueManager.remove(songQueueItem) + } + } + sealed class ViewState { data class Scanning(val progress: Progress?) : ViewState() data object Loading : 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 index c56f03e6b..358f32454 100644 --- 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 @@ -28,6 +28,7 @@ fun SongMenu( onSongInfo: (Song) -> Unit, onExclude: (Song) -> Unit, onEditTags: (Song) -> Unit, + onDelete: (Song) -> Unit, modifier: Modifier = Modifier, ) { var isMenuOpened by remember { mutableStateOf(false) } @@ -84,6 +85,16 @@ fun SongMenu( }, ) } + + if (song.externalId == null) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_delete)) }, + onClick = { + onDelete(song) + isMenuOpened = false + } + ) + } } } } From ace9180c7c5c1fc572a62d1374a1f610f292a429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 29 Mar 2025 11:29:24 +0100 Subject: [PATCH 11/31] Add "Add to playlist" context menu entry --- .../library/songs/AddToPlaylistSubmenu.kt | 49 +++++++++++++++++++ .../ui/screens/library/songs/SongList.kt | 38 +++++++++----- .../screens/library/songs/SongListFragment.kt | 15 +++++- .../ui/screens/library/songs/SongListItem.kt | 9 ++++ .../ui/screens/library/songs/SongMenu.kt | 25 ++++++++++ 5 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/AddToPlaylistSubmenu.kt diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/AddToPlaylistSubmenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/AddToPlaylistSubmenu.kt new file mode 100644 index 000000000..7a21b4b39 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/AddToPlaylistSubmenu.kt @@ -0,0 +1,49 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +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.Playlist +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData + +@Composable +fun AddToPlaylistSubmenu( + song: Song, + playlists: List, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + onShowCreatePlaylistDialog: (song: Song) -> Unit, + modifier: Modifier = Modifier, + expanded: Boolean = false, + onDismiss: () -> Unit = {}, +) { + val playlistData = PlaylistData.Songs(song) + + DropdownMenu( + modifier = modifier, + expanded = expanded, + onDismissRequest = onDismiss + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.playlist_menu_create_playlist)) }, + onClick = { + onShowCreatePlaylistDialog(song) + onDismiss() + } + ) + + for (playlist in playlists) { + DropdownMenuItem( + text = { Text(playlist.name) }, + onClick = { + onAddToPlaylist(playlist, playlistData) + onDismiss() + } + ) + } + } +} 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 index 6c03008e0..2fb85ea34 100644 --- 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 @@ -16,22 +16,27 @@ import androidx.compose.ui.platform.testTag 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.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.screens.playlistmenu.PlaylistData @Composable fun SongList( viewState: SongListViewModel.ViewState, + playlists: List, + 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, modifier: Modifier = Modifier, - onAddToQueue: (Song) -> Unit = {}, - onPlayNext: (Song) -> Unit = {}, - onSongInfo: (Song) -> Unit = {}, - onExclude: (Song) -> Unit = {}, - onEditTags: (Song) -> Unit = {}, - onDelete: (Song) -> Unit = {}, ) { when (viewState) { is SongListViewModel.ViewState.Scanning -> { @@ -66,7 +71,10 @@ fun SongList( } else { SongList( songs = viewState.songs, + playlists = playlists, onAddToQueue = onAddToQueue, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, onPlayNext = onPlayNext, onSongInfo = onSongInfo, onExclude = onExclude, @@ -81,13 +89,16 @@ fun SongList( @Composable private fun SongList( songs: List, + playlists: List, + 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, modifier: Modifier = Modifier, - onAddToQueue: (Song) -> Unit = {}, - onPlayNext: (Song) -> Unit = {}, - onSongInfo: (Song) -> Unit = {}, - onExclude: (Song) -> Unit = {}, - onEditTags: (Song) -> Unit = {}, - onDelete: (Song) -> Unit = {}, ) { val state = rememberLazyListState() @@ -103,7 +114,10 @@ private fun SongList( items(songs) { song -> SongListItem( song = song, + playlists = playlists, onAddToQueue = onAddToQueue, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, onPlayNext = onPlayNext, onSongInfo = onSongInfo, onExclude = onExclude, 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 a156cbf38..f39921dfe 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 @@ -43,6 +43,8 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuView import com.simplecityapps.shuttle.ui.screens.songinfo.SongInfoDialogFragment import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -109,14 +111,17 @@ class SongListFragment : setHasOptionsMenu(true) playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) + playlistMenuPresenter.bindView(playlistMenuView) composeView = view.findViewById(R.id.composeView) composeView.setContent { val viewState by viewModel.viewState.collectAsState() + val playlists by playlistMenuPresenter.playlistsState.collectAsState() SongList( viewState = viewState, + playlists = playlists.toImmutableList(), onAddToQueue = { song -> viewModel.addToQueue(song) { result -> result.onSuccess { song -> @@ -124,6 +129,15 @@ class SongListFragment : } } }, + 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 -> @@ -192,7 +206,6 @@ class SongListFragment : updateContextualToolbar() presenter.bindView(this) - playlistMenuPresenter.bindView(playlistMenuView) } override fun onCreateOptionsMenu( 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 index fc976689c..7b823f29b 100644 --- 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 @@ -15,9 +15,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp 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.screens.playlistmenu.PlaylistData import com.simplecityapps.shuttle.ui.theme.AppTheme import com.squareup.phrase.ListPhrase import kotlin.time.Instant @@ -26,8 +28,11 @@ import kotlinx.datetime.LocalDate @Composable fun SongListItem( song: Song, + playlists: List, modifier: Modifier = Modifier, onAddToQueue: (Song) -> Unit = {}, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit = { _, _ -> }, + onShowCreatePlaylistDialog: (song: Song) -> Unit = {}, onPlayNext: (Song) -> Unit = {}, onSongInfo: (Song) -> Unit = {}, onExclude: (Song) -> Unit = {}, @@ -65,7 +70,10 @@ fun SongListItem( } SongMenu( song = song, + playlists = playlists, onAddToQueue = onAddToQueue, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, onPlayNext = onPlayNext, onSongInfo = onSongInfo, onExclude = onExclude, @@ -111,6 +119,7 @@ private fun SongListItemPreview() { sampleRate = null, channelCount = null, ), + playlists = emptyList(), ) } } 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 index 358f32454..4232c5c8e 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -18,20 +19,26 @@ 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.playlistmenu.PlaylistData @Composable fun SongMenu( song: Song, + playlists: List, 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, @@ -54,6 +61,16 @@ fun SongMenu( 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 = { @@ -96,5 +113,13 @@ fun SongMenu( ) } } + AddToPlaylistSubmenu( + song = song, + expanded = isAddToPlaylistSubmenuOpen, + onDismiss = { isAddToPlaylistSubmenuOpen = false }, + playlists = playlists, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, + ) } } From 62b6b06a15e3901fbe2ef181b46ded86a180f061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 6 Apr 2025 12:32:15 +0200 Subject: [PATCH 12/31] Play songs when clicked --- .../shuttle/ui/screens/library/songs/SongList.kt | 4 ++++ .../ui/screens/library/songs/SongListFragment.kt | 7 +++++++ .../ui/screens/library/songs/SongListItem.kt | 5 ++++- .../ui/screens/library/songs/SongListViewModel.kt | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) 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 index 2fb85ea34..f2fd5c611 100644 --- 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 @@ -28,6 +28,7 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData fun SongList( viewState: SongListViewModel.ViewState, playlists: List, + onSongClicked: (Song) -> Unit, onAddToQueue: (Song) -> Unit, onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, onShowCreatePlaylistDialog: (song: Song) -> Unit, @@ -72,6 +73,7 @@ fun SongList( SongList( songs = viewState.songs, playlists = playlists, + onSongClicked = onSongClicked, onAddToQueue = onAddToQueue, onAddToPlaylist = onAddToPlaylist, onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, @@ -90,6 +92,7 @@ fun SongList( private fun SongList( songs: List, playlists: List, + onSongClicked: (Song) -> Unit, onAddToQueue: (Song) -> Unit, onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, onShowCreatePlaylistDialog: (song: Song) -> Unit, @@ -115,6 +118,7 @@ private fun SongList( SongListItem( song = song, playlists = playlists, + onSongClicked = onSongClicked, onAddToQueue = onAddToQueue, onAddToPlaylist = onAddToPlaylist, onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, 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 f39921dfe..0d4163159 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 @@ -122,6 +122,13 @@ class SongListFragment : SongList( viewState = viewState, playlists = playlists.toImmutableList(), + onSongClicked = { song -> + viewModel.play(song) { result -> + result.onFailure { error -> + showLoadError(error as Error) + } + } + }, onAddToQueue = { song -> viewModel.addToQueue(song) { result -> result.onSuccess { song -> 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 index 7b823f29b..57ed709d7 100644 --- 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 @@ -1,6 +1,7 @@ package com.simplecityapps.shuttle.ui.screens.library.songs import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -30,6 +31,7 @@ fun SongListItem( song: Song, playlists: List, modifier: Modifier = Modifier, + onSongClicked: (Song) -> Unit = {}, onAddToQueue: (Song) -> Unit = {}, onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit = { _, _ -> }, onShowCreatePlaylistDialog: (song: Song) -> Unit = {}, @@ -46,7 +48,8 @@ fun SongListItem( Column( Modifier .padding(start = 8.dp) - .weight(1f), + .weight(1f) + .clickable { onSongClicked(song) }, ) { Text( modifier = Modifier.fillMaxWidth(), 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 index 4578a3a9a..ebf74d0c1 100644 --- 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 @@ -59,6 +59,21 @@ class SongListViewModel @Inject constructor( .launchIn(viewModelScope) } + 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)) From 03b15e3be91c407a2ccd43a6a50107dda44dc9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 6 Apr 2025 19:20:36 +0200 Subject: [PATCH 13/31] Add shuffle action item to the song list --- .../screens/library/songs/ShuffleListItem.kt | 59 +++++++++++++++++++ .../ui/screens/library/songs/SongList.kt | 6 ++ .../screens/library/songs/SongListFragment.kt | 25 +++----- .../library/songs/SongListPresenter.kt | 29 +-------- .../library/songs/SongListViewModel.kt | 20 +++++++ 5 files changed, 95 insertions(+), 44 deletions(-) create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/ShuffleListItem.kt 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 index f2fd5c611..77d1c94e4 100644 --- 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 @@ -37,6 +37,7 @@ fun SongList( onExclude: (Song) -> Unit, onEditTags: (Song) -> Unit, onDelete: (Song) -> Unit, + onShuffle: () -> Unit, modifier: Modifier = Modifier, ) { when (viewState) { @@ -82,6 +83,7 @@ fun SongList( onExclude = onExclude, onEditTags = onEditTags, onDelete = onDelete, + onShuffle = onShuffle, ) } } @@ -101,6 +103,7 @@ private fun SongList( onExclude: (Song) -> Unit, onEditTags: (Song) -> Unit, onDelete: (Song) -> Unit, + onShuffle: () -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() @@ -114,6 +117,9 @@ private fun SongList( contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp), state = state, ) { + item { + ShuffleListItem(onClick = onShuffle) + } items(songs) { song -> SongListItem( song = song, 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 0d4163159..f13961b3a 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 @@ -12,6 +12,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader @@ -35,7 +36,6 @@ import com.simplecityapps.shuttle.ui.common.recyclerview.GlidePreloadModelProvid 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 @@ -65,8 +65,6 @@ class SongListFragment : lateinit var imageLoader: GlideImageLoader - private lateinit var shuffleBinder: ShuffleBinder - private lateinit var playlistMenuView: PlaylistMenuView private var circularLoadingView: CircularLoadingView by autoCleared() @@ -170,6 +168,13 @@ class SongListFragment : } } }, + onShuffle = { + viewModel.shuffle { result -> + result.onFailure { error -> + showLoadError(error as Error) + } + } + } ) } @@ -196,16 +201,6 @@ class SongListFragment : 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() - } - } - ) - savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } contextualToolbarHelper = ContextualToolbarHelper() @@ -344,10 +339,6 @@ class SongListFragment : } }.toMutableList() - if (songs.isNotEmpty()) { - data.add(0, shuffleBinder) - } - adapter.update(data) { /* recyclerViewState?.let { 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 index 454a02725..211f40ad6 100644 --- 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 @@ -6,7 +6,6 @@ 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 @@ -20,16 +19,14 @@ 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 +import java.util.Locale +import javax.inject.Inject interface SongListContract { sealed class LoadingState { @@ -77,10 +74,6 @@ interface SongListContract { fun setSortOrder(songSortOrder: SongSortOrder) fun getFastscrollPrefix(song: Song): String? - - fun updateToolbarMenu() - - fun shuffle() } } @@ -223,22 +216,4 @@ constructor( 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 index ebf74d0c1..0330a58e0 100644 --- 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 @@ -114,6 +114,26 @@ class SongListViewModel @Inject constructor( } } + 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() + } + sealed class ViewState { data class Scanning(val progress: Progress?) : ViewState() data object Loading : ViewState() From 73b56ece685e53c02a65fba6c8298601ad59ea69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 19 Apr 2025 13:13:08 +0200 Subject: [PATCH 14/31] Implement song selection with text mark --- .../ui/screens/library/songs/SongList.kt | 15 +++-- .../screens/library/songs/SongListFragment.kt | 64 +++++++++++++++---- .../ui/screens/library/songs/SongListItem.kt | 15 ++++- .../library/songs/SongListViewModel.kt | 46 ++++++++++++- 4 files changed, 117 insertions(+), 23 deletions(-) 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 index 77d1c94e4..2479c68a7 100644 --- 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 @@ -28,7 +28,8 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData fun SongList( viewState: SongListViewModel.ViewState, playlists: List, - onSongClicked: (Song) -> Unit, + onSongClick: (Song) -> Unit, + onSongLongClick: (Song) -> Unit, onAddToQueue: (Song) -> Unit, onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, onShowCreatePlaylistDialog: (song: Song) -> Unit, @@ -73,8 +74,10 @@ fun SongList( } else { SongList( songs = viewState.songs, + selectedSongs = viewState.selectedSongs, playlists = playlists, - onSongClicked = onSongClicked, + onSongClick = onSongClick, + onSongLongClick = onSongLongClick, onAddToQueue = onAddToQueue, onAddToPlaylist = onAddToPlaylist, onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, @@ -93,8 +96,10 @@ fun SongList( @Composable private fun SongList( songs: List, + selectedSongs: Set, playlists: List, - onSongClicked: (Song) -> Unit, + onSongClick: (Song) -> Unit, + onSongLongClick: (Song) -> Unit, onAddToQueue: (Song) -> Unit, onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, onShowCreatePlaylistDialog: (song: Song) -> Unit, @@ -123,8 +128,10 @@ private fun SongList( items(songs) { song -> SongListItem( song = song, + isSelected = selectedSongs.contains(song), playlists = playlists, - onSongClicked = onSongClicked, + onClick = onSongClick, + onLongClick = onSongLongClick, onAddToQueue = onAddToQueue, onAddToPlaylist = onAddToPlaylist, onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, 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 f13961b3a..f668e18dc 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader import au.com.simplecityapps.shuttle.imageloading.glide.GlideImageLoader import com.bumptech.glide.util.ViewPreloadSizeProvider @@ -113,6 +114,50 @@ class SongListFragment : composeView = view.findViewById(R.id.composeView) + circularLoadingView = view.findViewById(R.id.circularLoadingView) + horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) + + savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } + + contextualToolbarHelper = ContextualToolbarHelper() + updateContextualToolbar() + +/* + lifecycleScope.launch { + viewModel.viewState + .filterIsInstance() + .collect { state -> + contextualToolbarCallback.onCountChanged(state.selectedSongs.size) + } + } +*/ + + lifecycleScope.launch { + viewModel.selectedSongCountState + .collect { count -> + if (count == 0) { + contextualToolbarHelper.let { + it.toolbar?.isVisible = true + it.contextualToolbar?.isVisible = false + } + } else { + contextualToolbarHelper.let { + it.toolbar?.isVisible = false + it.contextualToolbar?.isVisible = true + } + + // onCountChanged() + 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()) + } + } + } + } + composeView.setContent { val viewState by viewModel.viewState.collectAsState() val playlists by playlistMenuPresenter.playlistsState.collectAsState() @@ -120,13 +165,16 @@ class SongListFragment : SongList( viewState = viewState, playlists = playlists.toImmutableList(), - onSongClicked = { song -> - viewModel.play(song) { result -> + 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 -> @@ -198,15 +246,6 @@ class SongListFragment : recyclerView.addOnScrollListener(preloader) */ - circularLoadingView = view.findViewById(R.id.circularLoadingView) - horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) - - savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } - - contextualToolbarHelper = ContextualToolbarHelper() - - updateContextualToolbar() - presenter.bindView(this) } @@ -297,8 +336,7 @@ class SongListFragment : } when (menuItem.itemId) { R.id.queue -> { - presenter.addToQueue(contextualToolbarHelper.selectedItems.toList()) - contextualToolbarHelper.hide() + viewModel.addSelectedToQueue() true } R.id.editTags -> { 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 index 57ed709d7..261d48164 100644 --- 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 @@ -1,7 +1,8 @@ package com.simplecityapps.shuttle.ui.screens.library.songs import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -26,12 +27,15 @@ import com.squareup.phrase.ListPhrase import kotlin.time.Instant import kotlinx.datetime.LocalDate +@OptIn(ExperimentalFoundationApi::class) @Composable fun SongListItem( song: Song, + isSelected: Boolean, playlists: List, modifier: Modifier = Modifier, - onSongClicked: (Song) -> Unit = {}, + onClick: (Song) -> Unit = {}, + onLongClick: (Song) -> Unit = {}, onAddToQueue: (Song) -> Unit = {}, onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit = { _, _ -> }, onShowCreatePlaylistDialog: (song: Song) -> Unit = {}, @@ -49,7 +53,10 @@ fun SongListItem( Modifier .padding(start = 8.dp) .weight(1f) - .clickable { onSongClicked(song) }, + .combinedClickable( + onClick = { onClick(song) }, + onLongClick = { onLongClick(song) }, + ), ) { Text( modifier = Modifier.fillMaxWidth(), @@ -63,6 +70,7 @@ fun SongListItem( .from(" • ") .joinSafely( listOf( + if (isSelected) "[x]" else "[ ]", song.friendlyArtistName ?: song.albumArtist, song.album ) @@ -122,6 +130,7 @@ private fun SongListItemPreview() { sampleRate = null, channelCount = null, ), + isSelected = true, playlists = emptyList(), ) } 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 index 0330a58e0..6c1dde7ef 100644 --- 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 @@ -24,9 +24,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch + @OpenForTesting @HiltViewModel class SongListViewModel @Inject constructor( @@ -40,17 +42,22 @@ class SongListViewModel @Inject constructor( private val _viewState = MutableStateFlow(ViewState.Loading) val viewState = _viewState.asStateFlow() + private val selectedSongsState = MutableStateFlow(emptySet()) + val selectedSongCountState = selectedSongsState.asStateFlow() + .map { selectedSongs -> selectedSongs.size } + init { combine( songRepository .getSongs(SongQuery.All(sortOrder = sortPreferenceManager.sortOrderSongList)) .filterNotNull(), mediaImportObserver.songImportState, - ) { songs, songImportState -> + selectedSongsState, + ) { songs, songImportState, selectedSongs -> if (songImportState is SongImportState.ImportProgress) { _viewState.emit(ViewState.Scanning(songImportState.progress)) } else { - _viewState.emit(ViewState.Ready(songs)) + _viewState.emit(ViewState.Ready(songs, selectedSongs)) } } .onStart { @@ -59,6 +66,27 @@ class SongListViewModel @Inject constructor( .launchIn(viewModelScope) } + fun onSongClick(song: Song, completion: (Result) -> Unit) { + if (isSelecting()) { + toggleSongSelection(song) + completion(Result.success(true)) + } else { + play(song, completion) + } + } + + fun onSongLongClick(song: Song) { + toggleSongSelection(song) + } + + private fun toggleSongSelection(song: Song) { + selectedSongsState.value = if (selectedSongsState.value.contains(song)) { + selectedSongsState.value - song + } else { + selectedSongsState.value + song + } + } + private fun play(song: Song, completion: (Result) -> Unit) { viewModelScope.launch { val songs = viewState.value.let { @@ -81,6 +109,13 @@ class SongListViewModel @Inject constructor( } } + fun addSelectedToQueue() { + viewModelScope.launch { + playbackManager.addToQueue(selectedSongsState.value.toList()) + selectedSongsState.value = emptySet() + } + } + fun playNext(song: Song, completion: (Result) -> Unit) { viewModelScope.launch { playbackManager.playNext(listOf(song)) @@ -134,9 +169,14 @@ class SongListViewModel @Inject constructor( if (it is ViewState.Ready) it.songs else emptyList() } + private fun isSelecting() = selectedSongsState.value.isNotEmpty() + sealed class ViewState { data class Scanning(val progress: Progress?) : ViewState() data object Loading : ViewState() - data class Ready(val songs: List) : ViewState() + data class Ready( + val songs: List, + val selectedSongs: Set, + ) : ViewState() } } From dcfaa60487413c2d4c116fca1324875ed69946f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Mon, 7 Jul 2025 20:25:23 +0200 Subject: [PATCH 15/31] Implement sorting --- .../ui/screens/library/songs/SongList.kt | 14 +++++- .../screens/library/songs/SongListFragment.kt | 48 ++++++++----------- .../library/songs/SongListPresenter.kt | 23 +++------ .../library/songs/SongListViewModel.kt | 29 ++++++++++- .../local/data/room/dao/SongDataDao.kt | 1 + 5 files changed, 66 insertions(+), 49 deletions(-) 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 index 2479c68a7..bfea22509 100644 --- 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 @@ -19,10 +19,12 @@ import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Playlist import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.ui.common.components.CircularLoadingState +import com.simplecityapps.shuttle.sorting.SongSortOrder 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.screens.playlistmenu.PlaylistData +import java.util.Locale @Composable fun SongList( @@ -75,6 +77,7 @@ fun SongList( SongList( songs = viewState.songs, selectedSongs = viewState.selectedSongs, + sortOrder = viewState.sortOrder, playlists = playlists, onSongClick = onSongClick, onSongLongClick = onSongLongClick, @@ -97,6 +100,7 @@ fun SongList( private fun SongList( songs: List, selectedSongs: Set, + sortOrder: SongSortOrder, playlists: List, onSongClick: (Song) -> Unit, onSongLongClick: (Song) -> Unit, @@ -147,8 +151,16 @@ private fun SongList( modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), state = state, getPopupText = { index -> - (songs)[index].name?.firstOrNull()?.toString() ?: "" // FIXME + getFastscrollPopupText(songs[index], 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 +} ?: "" 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 f668e18dc..55f5d98af 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 @@ -15,7 +15,9 @@ import androidx.compose.ui.platform.ComposeView import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader import au.com.simplecityapps.shuttle.imageloading.glide.GlideImageLoader import com.bumptech.glide.util.ViewPreloadSizeProvider @@ -132,7 +134,7 @@ class SongListFragment : } */ - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.selectedSongCountState .collect { count -> if (count == 0) { @@ -158,6 +160,15 @@ class SongListFragment : } } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.selectedSortOrder + .collect { sortOrder -> + updateToolbarMenuSortOrder(sortOrder) + } + } + } + composeView.setContent { val viewState by viewModel.viewState.collectAsState() val playlists by playlistMenuPresenter.playlistsState.collectAsState() @@ -226,26 +237,6 @@ class SongListFragment : ) } -/* - 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) -*/ - presenter.bindView(this) } @@ -256,8 +247,7 @@ class SongListFragment : super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.menu_song_list, menu) - - presenter.updateToolbarMenu() + updateToolbarMenuSortOrder(viewModel.selectedSortOrder.value) } override fun onResume() { @@ -294,27 +284,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 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 index 211f40ad6..935f6d14d 100644 --- 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 @@ -71,9 +71,9 @@ interface SongListContract { fun delete(song: Song) - fun setSortOrder(songSortOrder: SongSortOrder) - fun getFastscrollPrefix(song: Song): String? + + fun updateToolbarMenu() } } @@ -104,8 +104,6 @@ constructor( override fun bindView(view: SongListContract.View) { super.bindView(view) - - view.updateToolbarMenuSortOrder(sortPreferenceManager.sortOrderSongList) } override fun unbindView() { @@ -196,19 +194,6 @@ constructor( } } - 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()) @@ -216,4 +201,8 @@ constructor( SongSortOrder.Year -> song.date?.year?.toString() else -> null } + + 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 index 6c1dde7ef..048d6c4a0 100644 --- 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 @@ -10,15 +10,18 @@ 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.query.SongQuery +import com.simplecityapps.shuttle.sorting.SongSortOrder 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 @@ -27,6 +30,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber @OpenForTesting @@ -46,6 +51,9 @@ class SongListViewModel @Inject constructor( val selectedSongCountState = selectedSongsState.asStateFlow() .map { selectedSongs -> selectedSongs.size } + private val _selectedSortOrder = MutableStateFlow(sortPreferenceManager.sortOrderSongList) + val selectedSortOrder = _selectedSortOrder.asStateFlow() + init { combine( songRepository @@ -53,11 +61,13 @@ class SongListViewModel @Inject constructor( .filterNotNull(), mediaImportObserver.songImportState, selectedSongsState, - ) { songs, songImportState, selectedSongs -> + _selectedSortOrder, + ) { songs, songImportState, selectedSongs, __selectedSortOrder -> if (songImportState is SongImportState.ImportProgress) { _viewState.emit(ViewState.Scanning(songImportState.progress)) } else { - _viewState.emit(ViewState.Ready(songs, selectedSongs)) + val sortedSongs = songs.sortedWith(__selectedSortOrder.comparator) + _viewState.emit(ViewState.Ready(sortedSongs, selectedSongs, __selectedSortOrder)) } } .onStart { @@ -80,6 +90,7 @@ class SongListViewModel @Inject constructor( } private fun toggleSongSelection(song: Song) { + Timber.d("foo: toggleSongSelection: ${hashCode()}") selectedSongsState.value = if (selectedSongsState.value.contains(song)) { selectedSongsState.value - song } else { @@ -171,12 +182,26 @@ class SongListViewModel @Inject constructor( private fun isSelecting() = selectedSongsState.value.isNotEmpty() + 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/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() } From 6bae1563d127f8d3a184eb8c82e6a60254e8234c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Tue, 12 Aug 2025 00:23:55 +0200 Subject: [PATCH 16/31] Remove no longer needed presenter --- .../screens/library/songs/SongListFragment.kt | 162 +------------- .../library/songs/SongListPresenter.kt | 208 ------------------ 2 files changed, 6 insertions(+), 364 deletions(-) delete mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListPresenter.kt 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 55f5d98af..b02b71787 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 @@ -8,7 +8,6 @@ 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 @@ -22,8 +21,6 @@ import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader import au.com.simplecityapps.shuttle.imageloading.glide.GlideImageLoader import com.bumptech.glide.util.ViewPreloadSizeProvider import com.simplecityapps.adapter.RecyclerAdapter -import com.simplecityapps.adapter.ViewBinder -import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.sorting.SongSortOrder @@ -32,12 +29,9 @@ 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.view.CircularLoadingView -import com.simplecityapps.shuttle.ui.common.view.HorizontalLoadingView import com.simplecityapps.shuttle.ui.common.view.findToolbarHost import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFragment import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData @@ -53,10 +47,7 @@ import javax.inject.Inject @AndroidEntryPoint class SongListFragment : Fragment(), - SongListContract.View, CreatePlaylistDialogFragment.Listener { - @Inject - lateinit var presenter: SongListPresenter @Inject lateinit var playlistMenuPresenter: PlaylistMenuPresenter @@ -70,13 +61,6 @@ class SongListFragment : 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() } @@ -116,11 +100,6 @@ class SongListFragment : composeView = view.findViewById(R.id.composeView) - circularLoadingView = view.findViewById(R.id.circularLoadingView) - horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) - - savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } - contextualToolbarHelper = ContextualToolbarHelper() updateContextualToolbar() @@ -236,8 +215,6 @@ class SongListFragment : } ) } - - presenter.bindView(this) } override fun onCreateOptionsMenu( @@ -253,8 +230,6 @@ class SongListFragment : override fun onResume() { super.onResume() - presenter.loadSongs(false) - updateContextualToolbar() } @@ -264,17 +239,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() @@ -348,36 +315,7 @@ class SongListFragment : } } - // 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() - - adapter.update(data) { -/* - recyclerViewState?.let { - recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) - recyclerViewState = null - } -*/ - } - } - - 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 @@ -393,7 +331,7 @@ class SongListFragment : } } - override fun showLoadError(error: Error) { + fun showLoadError(error: Error) { Toast.makeText(context, error.userDescription(resources), Toast.LENGTH_LONG).show() } @@ -401,7 +339,7 @@ class SongListFragment : TagEditorAlertDialog.newInstance(listOf(song)).show(childFragmentManager) } - override fun onAddedToQueue(songs: List) { + fun onAddedToQueue(songs: List) { Toast.makeText( context, Phrase.fromPlural(resources, R.plurals.queue_songs_added, songs.size) @@ -411,108 +349,20 @@ 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 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 935f6d14d..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListPresenter.kt +++ /dev/null @@ -1,208 +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.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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import java.util.Locale -import javax.inject.Inject - -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 getFastscrollPrefix(song: Song): String? - - fun updateToolbarMenu() - } -} - -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) - } - - 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 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 updateToolbarMenu() { - view?.updateToolbarMenuSortOrder(sortPreferenceManager.sortOrderSongList) - } -} From b73b163055dfc74f9060229ebfbb092da90c8fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 17 Aug 2025 19:01:06 +0200 Subject: [PATCH 17/31] Move selection handing to a new toolbar helper for Compose --- .../common/ComposeContextualToolbarHelper.kt | 60 ++++++++++++ .../screens/library/songs/SongListFragment.kt | 92 +++++-------------- .../library/songs/SongListViewModel.kt | 33 ++----- 3 files changed, 93 insertions(+), 92 deletions(-) create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt 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/screens/library/songs/SongListFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt index b02b71787..9c5d23eef 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 @@ -11,7 +11,6 @@ import android.widget.Toast import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView -import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -20,11 +19,9 @@ import androidx.lifecycle.repeatOnLifecycle import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader import au.com.simplecityapps.shuttle.imageloading.glide.GlideImageLoader import com.bumptech.glide.util.ViewPreloadSizeProvider -import com.simplecityapps.adapter.RecyclerAdapter 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 @@ -55,14 +52,11 @@ class SongListFragment : private var composeView: ComposeView by autoCleared() private val viewModel: SongListViewModel by viewModels() - private var adapter: RecyclerAdapter by autoCleared() lateinit var imageLoader: GlideImageLoader private lateinit var playlistMenuView: PlaylistMenuView - private var contextualToolbarHelper: ContextualToolbarHelper by autoCleared() - private val viewPreloadSizeProvider by lazy { ViewPreloadSizeProvider() } private val preloadModelProvider by lazy { GlidePreloadModelProvider( @@ -100,40 +94,26 @@ class SongListFragment : composeView = view.findViewById(R.id.composeView) - contextualToolbarHelper = ContextualToolbarHelper() updateContextualToolbar() -/* - lifecycleScope.launch { - viewModel.viewState - .filterIsInstance() - .collect { state -> - contextualToolbarCallback.onCountChanged(state.selectedSongs.size) - } - } -*/ - viewLifecycleOwner.lifecycleScope.launch { - viewModel.selectedSongCountState + viewModel.contextualToolbarHelper.selectedSongCountState .collect { count -> if (count == 0) { - contextualToolbarHelper.let { - it.toolbar?.isVisible = true - it.contextualToolbar?.isVisible = false - } + viewModel.contextualToolbarHelper.hide() } else { - contextualToolbarHelper.let { - it.toolbar?.isVisible = false - it.contextualToolbar?.isVisible = true - } + viewModel.contextualToolbarHelper.show() - // onCountChanged() - contextualToolbarHelper.contextualToolbar?.title = + viewModel.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()) + viewModel.contextualToolbarHelper.contextualToolbar?.menu?.let { menu -> + TagEditorMenuSanitiser.sanitise( + menu, + viewModel.contextualToolbarHelper + .selectedSongsMediaProviders() + ) } } } @@ -284,11 +264,15 @@ 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) { @@ -297,20 +281,20 @@ class SongListFragment : 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 + viewModel.contextualToolbarHelper.contextualToolbar = contextualToolbar + viewModel.contextualToolbarHelper.toolbar = toolbar - if (contextualToolbarHelper.selectedItems.isNotEmpty()) { - contextualToolbarHelper.show() + if (viewModel.contextualToolbarHelper.isSelecting()) { + viewModel.contextualToolbarHelper.show() } } } @@ -373,34 +357,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/SongListViewModel.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt index 048d6c4a0..2dbced6a8 100644 --- 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 @@ -17,6 +17,7 @@ import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Song 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 @@ -27,12 +28,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import timber.log.Timber - @OpenForTesting @HiltViewModel @@ -47,20 +45,18 @@ class SongListViewModel @Inject constructor( private val _viewState = MutableStateFlow(ViewState.Loading) val viewState = _viewState.asStateFlow() - private val selectedSongsState = MutableStateFlow(emptySet()) - val selectedSongCountState = selectedSongsState.asStateFlow() - .map { selectedSongs -> selectedSongs.size } - private val _selectedSortOrder = MutableStateFlow(sortPreferenceManager.sortOrderSongList) val selectedSortOrder = _selectedSortOrder.asStateFlow() + val contextualToolbarHelper = ComposeContextualToolbarHelper() + init { combine( songRepository .getSongs(SongQuery.All(sortOrder = sortPreferenceManager.sortOrderSongList)) .filterNotNull(), mediaImportObserver.songImportState, - selectedSongsState, + contextualToolbarHelper.selectedSongsState, _selectedSortOrder, ) { songs, songImportState, selectedSongs, __selectedSortOrder -> if (songImportState is SongImportState.ImportProgress) { @@ -77,8 +73,8 @@ class SongListViewModel @Inject constructor( } fun onSongClick(song: Song, completion: (Result) -> Unit) { - if (isSelecting()) { - toggleSongSelection(song) + if (contextualToolbarHelper.isSelecting()) { + contextualToolbarHelper.toggleSongSelection(song) completion(Result.success(true)) } else { play(song, completion) @@ -86,16 +82,7 @@ class SongListViewModel @Inject constructor( } fun onSongLongClick(song: Song) { - toggleSongSelection(song) - } - - private fun toggleSongSelection(song: Song) { - Timber.d("foo: toggleSongSelection: ${hashCode()}") - selectedSongsState.value = if (selectedSongsState.value.contains(song)) { - selectedSongsState.value - song - } else { - selectedSongsState.value + song - } + contextualToolbarHelper.toggleSongSelection(song) } private fun play(song: Song, completion: (Result) -> Unit) { @@ -122,8 +109,8 @@ class SongListViewModel @Inject constructor( fun addSelectedToQueue() { viewModelScope.launch { - playbackManager.addToQueue(selectedSongsState.value.toList()) - selectedSongsState.value = emptySet() + playbackManager.addToQueue(contextualToolbarHelper.selectedSongsState.value.toList()) + contextualToolbarHelper.clearSelection() } } @@ -180,8 +167,6 @@ class SongListViewModel @Inject constructor( if (it is ViewState.Ready) it.songs else emptyList() } - private fun isSelecting() = selectedSongsState.value.isNotEmpty() - fun setSortOrder(sortOrder: SongSortOrder) { if (sortPreferenceManager.sortOrderSongList == sortOrder) { return From 20ff9cc2c039d81531e0ff5fd023ed8959ca48aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Fri, 29 Aug 2025 10:23:46 +0200 Subject: [PATCH 18/31] Add song thumbnails --- android/app/build.gradle.kts | 1 + .../ui/screens/library/songs/SongListItem.kt | 21 ++++++++++++++++++- .../src/main/res/values/strings_library.xml | 2 ++ gradle/libs.versions.toml | 2 ++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aa0e1ff97..efd802a76 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) 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 index 261d48164..40b87ca99 100644 --- 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 @@ -6,15 +6,22 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.integration.compose.placeholder import com.simplecityapps.core.R import com.simplecityapps.shuttle.model.MediaProviderType import com.simplecityapps.shuttle.model.Playlist @@ -27,7 +34,10 @@ import com.squareup.phrase.ListPhrase import kotlin.time.Instant import kotlinx.datetime.LocalDate -@OptIn(ExperimentalFoundationApi::class) +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalGlideComposeApi::class, +) @Composable fun SongListItem( song: Song, @@ -49,6 +59,15 @@ fun SongListItem( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { + GlideImage( + model = song, + contentDescription = stringResource(com.simplecityapps.shuttle.R.string.artwork), + loading = placeholder(R.drawable.ic_placeholder_song_rounded), + modifier = Modifier + .width(40.dp) + .height(40.dp) + .clip(RoundedCornerShape(8.dp)), + ) Column( Modifier .padding(start = 8.dp) diff --git a/android/app/src/main/res/values/strings_library.xml b/android/app/src/main/res/values/strings_library.xml index 2ed93083f..218bbd829 100644 --- a/android/app/src/main/res/values/strings_library.xml +++ b/android/app/src/main/res/values/strings_library.xml @@ -30,4 +30,6 @@ No library tabs visible Song context menu + + Artwork diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5942098b4..f20d3253e 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" @@ -134,6 +135,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" } From 3369a28bec946b04ad1f77451943b7ddc9804ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Mon, 29 Sep 2025 22:50:11 +0200 Subject: [PATCH 19/31] Preload thumbnail images --- .../ui/screens/library/songs/SongList.kt | 31 +++++++++++++++++-- .../screens/library/songs/SongListFragment.kt | 27 ---------------- .../ui/screens/library/songs/SongListItem.kt | 31 ++++++++++++++++--- 3 files changed, 55 insertions(+), 34 deletions(-) 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 index bfea22509..96d04923f 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -8,14 +9,21 @@ 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.items 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.simplecityapps.shuttle.R +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.model.Playlist import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.ui.common.components.CircularLoadingState @@ -25,6 +33,7 @@ import com.simplecityapps.shuttle.ui.common.components.HorizontalLoadingView import com.simplecityapps.shuttle.ui.common.components.LoadingStatusIndicator import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData import java.util.Locale +import com.simplecityapps.shuttle.ui.common.utils.dp as dpToInt @Composable fun SongList( @@ -96,6 +105,7 @@ fun SongList( } } +@OptIn(ExperimentalGlideComposeApi::class) @Composable private fun SongList( songs: List, @@ -117,6 +127,20 @@ private fun SongList( ) { 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 @@ -129,11 +153,14 @@ private fun SongList( item { ShuffleListItem(onClick = onShuffle) } - items(songs) { song -> + 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, 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 9c5d23eef..f5abc5646 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 @@ -16,9 +16,6 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader -import au.com.simplecityapps.shuttle.imageloading.glide.GlideImageLoader -import com.bumptech.glide.util.ViewPreloadSizeProvider import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.sorting.SongSortOrder @@ -28,7 +25,6 @@ import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog import com.simplecityapps.shuttle.ui.common.dialog.showDeleteDialog 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.view.findToolbarHost import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFragment import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData @@ -53,18 +49,8 @@ class SongListFragment : private val viewModel: SongListViewModel by viewModels() - lateinit var imageLoader: GlideImageLoader - private lateinit var playlistMenuView: PlaylistMenuView - private val viewPreloadSizeProvider by lazy { ViewPreloadSizeProvider() } - private val preloadModelProvider by lazy { - GlidePreloadModelProvider( - imageLoader, - listOf(ArtworkImageLoader.Options.CacheDecodedResource) - ) - } - // Lifecycle override fun onCreate(savedInstanceState: Bundle?) { @@ -85,8 +71,6 @@ class SongListFragment : ) { super.onViewCreated(view, savedInstanceState) - imageLoader = GlideImageLoader(this) - setHasOptionsMenu(true) playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) @@ -337,17 +321,6 @@ class SongListFragment : Toast.makeText(requireContext(), error.userDescription(resources), Toast.LENGTH_LONG).show() } - // Private - -/* - private val songBinderListener = - object : SongBinder.Listener { - override fun onViewHolderCreated(holder: SongBinder.ViewHolder) { - viewPreloadSizeProvider.setView(holder.imageView) - } - } -*/ - // CreatePlaylistDialogFragment.Listener Implementation override fun onSave( 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 index 40b87ca99..7903f6dfb 100644 --- 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 @@ -1,6 +1,7 @@ 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.combinedClickable import androidx.compose.foundation.layout.Column @@ -9,19 +10,24 @@ 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.platform.LocalContext 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 @@ -33,6 +39,7 @@ import com.simplecityapps.shuttle.ui.theme.AppTheme import com.squareup.phrase.ListPhrase import kotlin.time.Instant import kotlinx.datetime.LocalDate +import com.simplecityapps.shuttle.ui.common.utils.dp as dpToInt @OptIn( ExperimentalFoundationApi::class, @@ -43,6 +50,7 @@ fun SongListItem( song: Song, isSelected: Boolean, playlists: List, + artworkPreloadRequestBuilder: RequestBuilder, modifier: Modifier = Modifier, onClick: (Song) -> Unit = {}, onLongClick: (Song) -> Unit = {}, @@ -65,9 +73,21 @@ fun SongListItem( loading = placeholder(R.drawable.ic_placeholder_song_rounded), modifier = Modifier .width(40.dp) - .height(40.dp) - .clip(RoundedCornerShape(8.dp)), - ) + .height(40.dp), + ) { + // 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) @@ -151,6 +171,7 @@ private fun SongListItemPreview() { ), isSelected = true, playlists = emptyList(), + artworkPreloadRequestBuilder = Glide.with(LocalContext.current).load(null as? String) ) } } From d4f8925ac2365c213388745cad70b7a26abd7386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 26 Oct 2025 18:55:29 +0100 Subject: [PATCH 20/31] Implement proper selection mark --- .../ui/screens/library/songs/SongListItem.kt | 86 +++++++++++++++---- .../src/main/res/values/strings_library.xml | 2 + 2 files changed, 71 insertions(+), 17 deletions(-) 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 index 7903f6dfb..cc57abf0a 100644 --- 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 @@ -3,19 +3,28 @@ 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 @@ -67,26 +76,30 @@ fun SongListItem( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { - GlideImage( - model = song, - contentDescription = stringResource(com.simplecityapps.shuttle.R.string.artwork), - loading = placeholder(R.drawable.ic_placeholder_song_rounded), + SelectionMark( + isSelected = isSelected, modifier = Modifier .width(40.dp) .height(40.dp), ) { - // 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) + 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 @@ -109,7 +122,6 @@ fun SongListItem( .from(" • ") .joinSafely( listOf( - if (isSelected) "[x]" else "[ ]", song.friendlyArtistName ?: song.albumArtist, song.album ) @@ -133,6 +145,46 @@ fun SongListItem( } } +@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 diff --git a/android/app/src/main/res/values/strings_library.xml b/android/app/src/main/res/values/strings_library.xml index 218bbd829..0d59f7371 100644 --- a/android/app/src/main/res/values/strings_library.xml +++ b/android/app/src/main/res/values/strings_library.xml @@ -32,4 +32,6 @@ Song context menu Artwork + + Selection mark From f04703aa40daef1b67862b1c149190ce61f67442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 8 Nov 2025 12:37:50 +0100 Subject: [PATCH 21/31] Pass missing modifier parameter to SongList --- .../simplecityapps/shuttle/ui/screens/library/songs/SongList.kt | 1 + 1 file changed, 1 insertion(+) 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 index 96d04923f..df1ec5867 100644 --- 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 @@ -99,6 +99,7 @@ fun SongList( onEditTags = onEditTags, onDelete = onDelete, onShuffle = onShuffle, + modifier = modifier, ) } } From 954ed261c4c6c425221cfd508ace028ca6981766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 8 Nov 2025 12:53:09 +0100 Subject: [PATCH 22/31] Extract condition to check if a song can be deleted into a new Song method --- .../simplecityapps/shuttle/ui/screens/library/songs/SongMenu.kt | 2 +- .../src/main/kotlin/com/simplecityapps/shuttle/model/Song.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 4232c5c8e..a76da1218 100644 --- 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 @@ -103,7 +103,7 @@ fun SongMenu( ) } - if (song.externalId == null) { + if (song.canBeDeleted()) { DropdownMenuItem( text = { Text(stringResource(id = R.string.menu_title_delete)) }, onClick = { 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 } From 584830d3bf1ae3c2cac68d44557587e0bab61e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 8 Nov 2025 13:05:56 +0100 Subject: [PATCH 23/31] Make QueueManager.remove also hande Song to simplify and avoid code repetition --- .../ui/screens/library/songs/SongListViewModel.kt | 9 ++------- .../com/simplecityapps/playback/queue/QueueManager.kt | 5 +++++ 2 files changed, 7 insertions(+), 7 deletions(-) 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 index 2dbced6a8..0bddde6aa 100644 --- 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 @@ -124,11 +124,7 @@ class SongListViewModel @Inject constructor( fun exclude(song: Song) { viewModelScope.launch { songRepository.setExcluded(listOf(song), true) - queueManager.remove( - queueManager - .getQueue() - .filter { queueItem -> song == queueItem.song }, - ) + queueManager.remove(song) } } @@ -142,8 +138,7 @@ class SongListViewModel @Inject constructor( viewModelScope.launch { songRepository.remove(song) - val songQueueItem = queueManager.getQueue().filter { it.song.id == song.id } - queueManager.remove(songQueueItem) + queueManager.remove(song) } } 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() From dc50ab916f7ee3339a4da9473fc3e93f5942a5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 9 Nov 2025 19:38:52 +0100 Subject: [PATCH 24/31] Use immutable types for composable paramters when possible --- .../ui/screens/library/songs/SongList.kt | 22 +++++++++++-------- .../ui/screens/library/songs/SongListItem.kt | 9 +++++--- .../ui/screens/library/songs/SongMenu.kt | 3 ++- 3 files changed, 21 insertions(+), 13 deletions(-) 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 index df1ec5867..0b2a66a9a 100644 --- 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 @@ -16,7 +16,6 @@ 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.simplecityapps.shuttle.R import com.bumptech.glide.RequestBuilder import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.rememberGlidePreloadingData @@ -24,21 +23,26 @@ 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.ui.common.components.CircularLoadingState 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.utils.dp as dpToInt import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData import java.util.Locale -import com.simplecityapps.shuttle.ui.common.utils.dp as dpToInt +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: List, + playlists: ImmutableList, onSongClick: (Song) -> Unit, onSongLongClick: (Song) -> Unit, onAddToQueue: (Song) -> Unit, @@ -84,8 +88,8 @@ fun SongList( ) } else { SongList( - songs = viewState.songs, - selectedSongs = viewState.selectedSongs, + songs = viewState.songs.toImmutableList(), + selectedSongs = viewState.selectedSongs.toImmutableSet(), sortOrder = viewState.sortOrder, playlists = playlists, onSongClick = onSongClick, @@ -109,10 +113,10 @@ fun SongList( @OptIn(ExperimentalGlideComposeApi::class) @Composable private fun SongList( - songs: List, - selectedSongs: Set, + songs: ImmutableList, + selectedSongs: ImmutableSet, sortOrder: SongSortOrder, - playlists: List, + playlists: ImmutableList, onSongClick: (Song) -> Unit, onSongLongClick: (Song) -> Unit, onAddToQueue: (Song) -> Unit, 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 index cc57abf0a..4484b6462 100644 --- 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 @@ -43,12 +43,15 @@ 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 -import com.simplecityapps.shuttle.ui.common.utils.dp as dpToInt @OptIn( ExperimentalFoundationApi::class, @@ -58,7 +61,7 @@ import com.simplecityapps.shuttle.ui.common.utils.dp as dpToInt fun SongListItem( song: Song, isSelected: Boolean, - playlists: List, + playlists: ImmutableList, artworkPreloadRequestBuilder: RequestBuilder, modifier: Modifier = Modifier, onClick: (Song) -> Unit = {}, @@ -222,7 +225,7 @@ private fun SongListItemPreview() { channelCount = null, ), isSelected = true, - playlists = emptyList(), + 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/SongMenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongMenu.kt index a76da1218..2f9ed87a7 100644 --- 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 @@ -22,11 +22,12 @@ import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Playlist import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData +import kotlinx.collections.immutable.ImmutableList @Composable fun SongMenu( song: Song, - playlists: List, + playlists: ImmutableList, onAddToQueue: (Song) -> Unit, onPlayNext: (Song) -> Unit, onSongInfo: (Song) -> Unit, From 00503e0b1ead68fd6832e31bb4a3210efcd914f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 9 Nov 2025 19:17:27 +0100 Subject: [PATCH 25/31] Merge duplicated (songs/genres) AddToPlaylistSubmenu into a parametrized one --- .../{genres => }/AddToPlaylistSubmenu.kt | 14 +++--- .../ui/screens/library/genres/GenreMenu.kt | 4 +- .../library/songs/AddToPlaylistSubmenu.kt | 49 ------------------- .../ui/screens/library/songs/SongMenu.kt | 6 ++- 4 files changed, 14 insertions(+), 59 deletions(-) rename android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/{genres => }/AddToPlaylistSubmenu.kt (79%) delete mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/AddToPlaylistSubmenu.kt 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/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/AddToPlaylistSubmenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/AddToPlaylistSubmenu.kt deleted file mode 100644 index 7a21b4b39..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/AddToPlaylistSubmenu.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.simplecityapps.shuttle.ui.screens.library.songs - -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Text -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.Playlist -import com.simplecityapps.shuttle.model.Song -import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData - -@Composable -fun AddToPlaylistSubmenu( - song: Song, - playlists: List, - onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, - onShowCreatePlaylistDialog: (song: Song) -> Unit, - modifier: Modifier = Modifier, - expanded: Boolean = false, - onDismiss: () -> Unit = {}, -) { - val playlistData = PlaylistData.Songs(song) - - DropdownMenu( - modifier = modifier, - expanded = expanded, - onDismissRequest = onDismiss - ) { - DropdownMenuItem( - text = { Text(stringResource(id = R.string.playlist_menu_create_playlist)) }, - onClick = { - onShowCreatePlaylistDialog(song) - onDismiss() - } - ) - - for (playlist in playlists) { - DropdownMenuItem( - text = { Text(playlist.name) }, - onClick = { - onAddToPlaylist(playlist, playlistData) - onDismiss() - } - ) - } - } -} 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 index 2f9ed87a7..96d9f5160 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -115,12 +116,13 @@ fun SongMenu( } } AddToPlaylistSubmenu( - song = song, + playableItem = song, expanded = isAddToPlaylistSubmenuOpen, onDismiss = { isAddToPlaylistSubmenuOpen = false }, playlists = playlists, onAddToPlaylist = onAddToPlaylist, - onShowCreatePlaylistDialog = onShowCreatePlaylistDialog, + playlistDataCreator = { song -> PlaylistData.Songs(song) }, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog ) } } From 9bd0513f09c5f3ab5a21c417b5fd3990d30c7423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 9 Nov 2025 21:14:36 +0100 Subject: [PATCH 26/31] Add theme support --- .../screens/library/songs/SongListFragment.kt | 122 ++++++++++-------- .../library/songs/SongListViewModel.kt | 5 + 2 files changed, 71 insertions(+), 56 deletions(-) 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 f5abc5646..f9eacce2a 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 @@ -14,6 +14,7 @@ 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.lifecycle.repeatOnLifecycle import com.simplecityapps.shuttle.R @@ -31,6 +32,7 @@ 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 kotlinx.collections.immutable.toImmutableList @@ -116,68 +118,76 @@ class SongListFragment : val viewState by viewModel.viewState.collectAsState() val playlists by playlistMenuPresenter.playlistsState.collectAsState() - SongList( - viewState = viewState, - playlists = playlists.toImmutableList(), - onSongClick = { song -> - viewModel.onSongClick(song) { result -> - result.onFailure { error -> - showLoadError(error as Error) + val theme by viewModel.theme.collectAsStateWithLifecycle() + val accent by viewModel.accent.collectAsStateWithLifecycle() + + 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)) + }, + 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)) + }, + 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) + }, + 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) + }, + onShuffle = { + viewModel.shuffle { result -> + result.onFailure { error -> + showLoadError(error as Error) + } } } - } - ) + ) + } } } 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 index 0bddde6aa..7058b4a93 100644 --- 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 @@ -15,6 +15,7 @@ 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 @@ -39,6 +40,7 @@ class SongListViewModel @Inject constructor( private val playbackManager: PlaybackManager, private val queueManager: QueueManager, private val sortPreferenceManager: SortPreferenceManager, + preferenceManager: GeneralPreferenceManager, mediaImportObserver: MediaImportObserver, application: Application, ) : AndroidViewModel(application) { @@ -50,6 +52,9 @@ class SongListViewModel @Inject constructor( val contextualToolbarHelper = ComposeContextualToolbarHelper() + val theme = preferenceManager.theme(viewModelScope) + val accent = preferenceManager.accent(viewModelScope) + init { combine( songRepository From 68abbe6b9bc209062d977e66b473aa9d4364737c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Fri, 19 Dec 2025 19:40:15 +0100 Subject: [PATCH 27/31] Clean up SongListFragment --- .../shuttle/ui/screens/library/songs/SongListFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f9eacce2a..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 @@ -35,9 +35,9 @@ 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 -import javax.inject.Inject @AndroidEntryPoint class SongListFragment : @@ -317,7 +317,7 @@ class SongListFragment : TagEditorAlertDialog.newInstance(listOf(song)).show(childFragmentManager) } - fun onAddedToQueue(songs: List) { + fun onAddedToQueue(songs: List) { Toast.makeText( context, Phrase.fromPlural(resources, R.plurals.queue_songs_added, songs.size) From 7f68b15d9d50c6b53809767e803fa9840257bbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 29 Nov 2025 13:10:44 +0100 Subject: [PATCH 28/31] Write unit tests for SongListViewModel --- android/app/build.gradle.kts | 3 + .../com/simplecityapps/creationFunctions.kt | 50 +++ .../library/songs/SongListViewModelTest.kt | 403 ++++++++++++++++++ .../src/test/java/com/simplecityapps/utils.kt | 6 + gradle/libs.versions.toml | 6 + 5 files changed, 468 insertions(+) create mode 100644 android/app/src/test/java/com/simplecityapps/creationFunctions.kt create mode 100644 android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt create mode 100644 android/app/src/test/java/com/simplecityapps/utils.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index efd802a76..bee21bef4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -260,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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index f20d3253e..a94899702 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,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" @@ -48,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" @@ -146,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" } @@ -158,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" } From 9985bf6874b24c56d38373fc0b5b8db9e53e3661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 20 Dec 2025 13:12:10 +0100 Subject: [PATCH 29/31] Clean up PlaylistMenuPresenter --- .../shuttle/ui/screens/playlistmenu/PlaylistMenuPresenter.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 7ab2a0ff8..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 @@ -17,10 +17,9 @@ 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 dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import timber.log.Timber From 2ba32203335d44a761c02194fd628814b0652e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 21 Dec 2025 13:16:21 +0100 Subject: [PATCH 30/31] Don't show scrollbar popup for certain sort orders As it was before the migration, no popup was shown for 'Last modified' and Duration sort orders. --- .../ui/common/components/FastScroller.kt | 13 +++++++++---- .../ui/screens/library/songs/SongList.kt | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) 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..8e905c45a 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) } } } @@ -261,6 +262,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/songs/SongList.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt index 0b2a66a9a..12f5c39a5 100644 --- 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 @@ -31,6 +31,7 @@ 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 @@ -185,6 +186,7 @@ private fun SongList( getPopupText = { index -> getFastscrollPopupText(songs[index], sortOrder) }, + popup = getFastscrollPopup(sortOrder) ) } } @@ -196,3 +198,18 @@ fun getFastscrollPopupText(song: Song, sortOrder: SongSortOrder): String = when 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 +} From 4882dbc42bc67a5c59c70b5031d6a813e019f0d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Tue, 30 Dec 2025 19:48:36 +0100 Subject: [PATCH 31/31] Fix long text not fitting properly in the fast scroller's popup This would happen, for example, when scrolling the song list with Year order selected. There wasn't space at the start and end of the popup bubble, which made it look cramped. --- .../shuttle/ui/common/components/FastScroller.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 8e905c45a..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 @@ -239,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, @@ -249,7 +249,8 @@ fun DefaultPopup( bottomStartPercent = 50, bottomEndPercent = 0 ) - ), + ) + .padding(start = 16.dp, end = 16.dp), contentAlignment = Alignment.Center ) { Text(