From fb9e985680256f91142aaad986a3d946c1d0d5a7 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:24:28 +0000 Subject: [PATCH] add star/unstar and long-press item menus to the library lists Star is a new write path: MusicSource.setStar posts star.view/unstar.view and LibraryRepository updates the local row on success (toast on failure). Long-press an album, artist, song or playlist in the library to open a bottom sheet with play/shuffle, star/unstar and view-album/view-artist. --- .../com/subtracks/data/db/LibraryDao.kt | 21 +++ .../subtracks/data/repo/LibraryRepository.kt | 29 +++ .../com/subtracks/data/source/MusicSource.kt | 12 ++ .../data/source/subsonic/SubsonicSource.kt | 17 ++ .../main/kotlin/com/subtracks/di/AppModule.kt | 16 +- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 2 + .../ui/components/ItemContextMenu.kt | 170 ++++++++++++++++++ .../com/subtracks/ui/library/AlbumsContent.kt | 10 +- .../subtracks/ui/library/ArtistsContent.kt | 10 +- .../com/subtracks/ui/library/LibraryScreen.kt | 29 +++ .../subtracks/ui/library/LibraryViewModel.kt | 33 ++++ .../subtracks/ui/library/PlaylistsContent.kt | 10 +- .../com/subtracks/ui/library/SongsContent.kt | 10 +- .../data/repo/LibraryRepositoryTest.kt | 134 ++++++++++++++ .../subtracks/data/sync/SyncServiceTest.kt | 7 + 15 files changed, 490 insertions(+), 20 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt create mode 100644 app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 3e17a550..6156896c 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -62,6 +62,27 @@ interface LibraryDao { @Upsert suspend fun upsertPlaylistSongs(items: List) + @Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id") + suspend fun setArtistStar( + sourceId: Long, + id: String, + starred: Long?, + ) + + @Query("UPDATE albums SET starred = :starred WHERE sourceId = :sourceId AND id = :id") + suspend fun setAlbumStar( + sourceId: Long, + id: String, + starred: Long?, + ) + + @Query("UPDATE songs SET starred = :starred WHERE sourceId = :sourceId AND id = :id") + suspend fun setSongStar( + sourceId: Long, + id: String, + starred: Long?, + ) + @Query("SELECT id FROM artists WHERE sourceId = :sourceId") suspend fun artistIds(sourceId: Long): List diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index bdf1d5ff..f2f6cc7d 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -16,11 +16,13 @@ import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.SongSort import com.subtracks.data.prefs.StarredFilter +import com.subtracks.data.source.StarType import kotlinx.coroutines.flow.Flow class LibraryRepository( private val db: SubtracksDatabase, private val sourceRepository: SourceRepository, + private val showMessage: (String) -> Unit = {}, ) { val activeSourceId: Flow = sourceRepository.activeSourceId() @@ -234,6 +236,33 @@ class LibraryRepository( playlistId: String, ): Flow> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) } + suspend fun setStar( + type: StarType, + id: String, + starred: Boolean, + ): Result { + val sourceId = sourceRepository.activeSourceIdOnce() + val source = sourceRepository.activeMusicSource() + if (sourceId == null || source == null) return Result.failure(IllegalStateException("No active server")) + return runCatching { source.setStar(type, id, starred) } + .onSuccess { updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null) } + .onFailure { showMessage("Could not update star") } + } + + private suspend fun updateStarred( + sourceId: Long, + type: StarType, + id: String, + starred: Long?, + ) { + val dao = db.libraryDao() + when (type) { + StarType.Song -> dao.setSongStar(sourceId, id, starred) + StarType.Album -> dao.setAlbumStar(sourceId, id, starred) + StarType.Artist -> dao.setArtistStar(sourceId, id, starred) + } + } + private fun pager( pageSize: Int, source: () -> PagingSource, diff --git a/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt index a0fb3856..9a88cc3f 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt @@ -12,6 +12,12 @@ interface MusicSource { suspend fun ping() + suspend fun setStar( + type: StarType, + id: String, + starred: Boolean, + ) + fun artists(): Flow> fun albums(): Flow> @@ -22,3 +28,9 @@ interface MusicSource { fun playlistSongs(playlistIds: List): Flow> } + +enum class StarType { + Song, + Album, + Artist, +} diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index 8dd79656..5505b397 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -6,6 +6,7 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song import com.subtracks.data.source.MusicSource +import com.subtracks.data.source.StarType import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -40,6 +41,22 @@ class SubsonicSource( withContext(Dispatchers.IO) { client.check("ping") } } + override suspend fun setStar( + type: StarType, + id: String, + starred: Boolean, + ) { + val param = + when (type) { + StarType.Song -> "id" + StarType.Album -> "albumId" + StarType.Artist -> "artistId" + } + withContext(Dispatchers.IO) { + client.check(if (starred) "star" else "unstar", mapOf(param to id)) + } + } + override fun artists(): Flow> = entityBatches( method = "getArtists", diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 4e7358f2..62a05dcf 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -32,20 +32,14 @@ fun appModule( http: OkHttpClient, ) = module { val mainHandler = Handler(Looper.getMainLooper()) + val toast: (String) -> Unit = { message -> + mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } + } single { createAndroidDatabase(context) } single { http } single { createUserPreferences(context) } - single { - SourceRepository( - get(), - get(), - get(), - showMessage = { message -> - mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } - }, - ) - } - single { LibraryRepository(get(), get()) } + single { SourceRepository(get(), get(), get(), showMessage = toast) } + single { LibraryRepository(get(), get(), toast) } single { ArtworkSeedRepository(get()) } single { SyncManager(get(), get(), get()) } single { QueueRepository(get()) } diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 5213e95a..80c98ef6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -220,6 +220,8 @@ private fun MainNavigation() { onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) }, + onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) }, bottomInset = bottomInset, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt new file mode 100644 index 00000000..88a4bd4e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -0,0 +1,170 @@ +package com.subtracks.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Album +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import com.subtracks.data.source.StarType +import com.subtracks.data.model.Album as AlbumModel +import com.subtracks.data.model.Artist as ArtistModel +import com.subtracks.data.model.Playlist as PlaylistModel +import com.subtracks.data.model.Song as SongModel + +sealed interface MenuTarget { + val title: String + val subtitle: String? + + data class Song( + val song: SongModel, + ) : MenuTarget { + override val title: String get() = song.title + + override val subtitle: String? get() = song.artist + } + + data class Album( + val album: AlbumModel, + ) : MenuTarget { + override val title: String get() = album.name + + override val subtitle: String? get() = album.albumArtist + } + + data class Artist( + val artist: ArtistModel, + ) : MenuTarget { + override val title: String get() = artist.name + + override val subtitle: String? get() = null + } + + data class Playlist( + val playlist: PlaylistModel, + ) : MenuTarget { + override val title: String get() = playlist.name + + override val subtitle: String? get() = null + } +} + +class ItemActions( + val playSong: (SongModel) -> Unit = {}, + val playAlbum: (AlbumModel) -> Unit = {}, + val shuffleAlbum: (AlbumModel) -> Unit = {}, + val playPlaylist: (PlaylistModel) -> Unit = {}, + val shufflePlaylist: (PlaylistModel) -> Unit = {}, + val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> }, + val viewAlbum: (String) -> Unit = {}, + val viewArtist: (String) -> Unit = {}, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemContextMenu( + target: MenuTarget, + actions: ItemActions, + onDismiss: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + val dismissThen: (() -> Unit) -> Unit = { action -> + onDismiss() + action() + } + MenuHeader(target) + when (target) { + is MenuTarget.Song -> { + MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playSong(target.song) } } + MenuItem(starIcon(target.song.starred), starLabel(target.song.starred)) { + dismissThen { actions.setStar(StarType.Song, target.song.id, target.song.starred == null) } + } + target.song.albumId?.let { albumId -> + MenuItem(Icons.Rounded.Album, "View album") { dismissThen { actions.viewAlbum(albumId) } } + } + target.song.artistId?.let { artistId -> + MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } } + } + } + + is MenuTarget.Album -> { + MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playAlbum(target.album) } } + MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shuffleAlbum(target.album) } } + MenuItem(starIcon(target.album.starred), starLabel(target.album.starred)) { + dismissThen { actions.setStar(StarType.Album, target.album.id, target.album.starred == null) } + } + target.album.artistId?.let { artistId -> + MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } } + } + } + + is MenuTarget.Artist -> { + MenuItem(starIcon(target.artist.starred), starLabel(target.artist.starred)) { + dismissThen { actions.setStar(StarType.Artist, target.artist.id, target.artist.starred == null) } + } + } + + is MenuTarget.Playlist -> { + MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playPlaylist(target.playlist) } } + MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shufflePlaylist(target.playlist) } } + } + } + } +} + +@Composable +private fun MenuHeader(target: MenuTarget) { + ListItem( + headlineContent = { + Text( + text = target.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = + target.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle -> + { + Text( + text = subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +@Composable +private fun MenuItem( + icon: ImageVector, + label: String, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(label) }, + leadingContent = { Icon(icon, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(onClick = onClick), + ) +} + +private fun starIcon(starred: Long?): ImageVector = if (starred == null) Icons.Rounded.StarBorder else Icons.Rounded.Star + +private fun starLabel(starred: Long?): String = if (starred == null) "Star" else "Unstar" diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 05597751..288f9281 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -1,6 +1,6 @@ package com.subtracks.ui.library -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.PrefetchArtworkSeeds @@ -40,6 +41,7 @@ fun AlbumsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onAlbumClick: (Album) -> Unit, + onLongClick: (MenuTarget) -> Unit = {}, filtered: Boolean = false, onClearFilters: () -> Unit = {}, resetKey: Any? = null, @@ -90,8 +92,10 @@ fun AlbumsContent( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(2.dp)) - .clickable { onAlbumClick(album) } - .testTag(ALBUM_COVER_TAG), + .combinedClickable( + onClick = { onAlbumClick(album) }, + onLongClick = { onLongClick(MenuTarget.Album(album)) }, + ).testTag(ALBUM_COVER_TAG), ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index 052a01d1..542c66c9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -1,6 +1,6 @@ package com.subtracks.ui.library -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.PrefetchArtworkSeeds @@ -38,6 +39,7 @@ fun ArtistsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onArtistClick: (Artist) -> Unit, + onLongClick: (MenuTarget) -> Unit = {}, filtered: Boolean = false, onClearFilters: () -> Unit = {}, resetKey: Any? = null, @@ -93,7 +95,11 @@ fun ArtistsContent( modifier = Modifier.size(48.dp).clip(CircleShape), ) }, - modifier = Modifier.clickable { onArtistClick(artist) }, + modifier = + Modifier.combinedClickable( + onClick = { onArtistClick(artist) }, + onLongClick = { onLongClick(MenuTarget.Artist(artist)) }, + ), colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 7521d2d3..47374770 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -114,6 +114,9 @@ import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.SongSort import com.subtracks.data.prefs.StarredFilter import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ItemActions +import com.subtracks.ui.components.ItemContextMenu +import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.librarySurfaceColor @@ -189,12 +192,15 @@ fun LibraryRoute( onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onOpenSettings: () -> Unit, + onViewAlbum: (String) -> Unit, + onViewArtist: (String) -> Unit, bottomInset: Dp, viewModel: LibraryViewModel = koinViewModel(), playbackController: PlaybackController = koinInject(), ) { var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } + var menuTarget by remember { mutableStateOf(null) } val syncing by viewModel.syncing.collectAsStateWithLifecycle() val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle() val listTab = selectedTab.listTab() @@ -231,6 +237,7 @@ fun LibraryRoute( onSongClick = viewModel::playSong, onSync = viewModel::sync, onOpenSettings = onOpenSettings, + onItemLongClick = { menuTarget = it }, playingSongId = playingSongId, listQuery = listQuery, resetKeys = resetKeys, @@ -254,6 +261,23 @@ fun LibraryRoute( search = search, onSearchChange = { viewModel.setSearch(listTab, it) }, ) + menuTarget?.let { target -> + ItemContextMenu( + target = target, + actions = + ItemActions( + playSong = { viewModel.playSong(it.id) }, + playAlbum = { viewModel.playAlbum(it.id) }, + shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, + playPlaylist = { viewModel.playPlaylist(it.id) }, + shufflePlaylist = { viewModel.shufflePlaylist(it.id) }, + setStar = viewModel::setStar, + viewAlbum = onViewAlbum, + viewArtist = onViewArtist, + ), + onDismiss = { menuTarget = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -272,6 +296,7 @@ fun LibraryScreen( onSongClick: (String) -> Unit, onSync: () -> Unit, onOpenSettings: () -> Unit, + onItemLongClick: (MenuTarget) -> Unit = {}, syncing: Boolean = false, playingSongId: String? = null, bottomInset: Dp = 0.dp, @@ -364,6 +389,7 @@ fun LibraryScreen( dismissSearch() onAlbumClick(it) }, + onLongClick = onItemLongClick, filtered = filtersActive, onClearFilters = onClearFilters, resetKey = resetKey, @@ -381,6 +407,7 @@ fun LibraryScreen( dismissSearch() onArtistClick(it) }, + onLongClick = onItemLongClick, filtered = filtersActive, onClearFilters = onClearFilters, resetKey = resetKey, @@ -398,6 +425,7 @@ fun LibraryScreen( dismissSearch() onSongClick(it) }, + onLongClick = onItemLongClick, playingSongId, filtered = filtersActive, onClearFilters = onClearFilters, @@ -416,6 +444,7 @@ fun LibraryScreen( dismissSearch() onPlaylistClick(it) }, + onLongClick = onItemLongClick, filtered = filtersActive, onClearFilters = onClearFilters, resetKey = resetKey, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 98b4acfa..c073fd40 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -20,6 +20,7 @@ import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.QueueSongQuery import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.source.StarType import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncStatus import com.subtracks.playback.PlaybackController @@ -138,6 +139,38 @@ class LibraryViewModel( } } + fun playAlbum(albumId: String) { + viewModelScope.launch { + sourceRepository.activeSourceIdOnce()?.let { playbackController.playAlbum(it, albumId, 0) } + } + } + + fun shuffleAlbum(albumId: String) { + viewModelScope.launch { + sourceRepository.activeSourceIdOnce()?.let { playbackController.shuffleAlbum(it, albumId) } + } + } + + fun playPlaylist(playlistId: String) { + viewModelScope.launch { + sourceRepository.activeSourceIdOnce()?.let { playbackController.playPlaylist(it, playlistId, 0) } + } + } + + fun shufflePlaylist(playlistId: String) { + viewModelScope.launch { + sourceRepository.activeSourceIdOnce()?.let { playbackController.shufflePlaylist(it, playlistId) } + } + } + + fun setStar( + type: StarType, + id: String, + starred: Boolean, + ) { + viewModelScope.launch { libraryRepository.setStar(type, id, starred) } + } + fun sync() = syncManager.requestSync() } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index cb32733a..bc5dd0d4 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -1,6 +1,6 @@ package com.subtracks.ui.library -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.rememberViewportFill @@ -37,6 +38,7 @@ fun PlaylistsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onPlaylistClick: (Playlist) -> Unit, + onLongClick: (MenuTarget) -> Unit = {}, filtered: Boolean = false, onClearFilters: () -> Unit = {}, resetKey: Any? = null, @@ -91,7 +93,11 @@ fun PlaylistsContent( modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), ) }, - modifier = Modifier.clickable { onPlaylistClick(playlist) }, + modifier = + Modifier.combinedClickable( + onClick = { onPlaylistClick(playlist) }, + onLongClick = { onLongClick(MenuTarget.Playlist(playlist)) }, + ), colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index 8a018262..442613db 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -1,6 +1,6 @@ package com.subtracks.ui.library -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -39,6 +39,7 @@ import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.rememberViewportFill @@ -48,6 +49,7 @@ fun SongsContent( coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, onSongClick: (String) -> Unit, + onLongClick: (MenuTarget) -> Unit = {}, playingSongId: String? = null, filtered: Boolean = false, onClearFilters: () -> Unit = {}, @@ -86,7 +88,11 @@ fun SongsContent( coverArtId = item.coverArt, coverArt = coverArt, isPlaying = item.song.id == playingSongId, - modifier = Modifier.clickable { onSongClick(item.song.id) }, + modifier = + Modifier.combinedClickable( + onClick = { onSongClick(item.song.id) }, + onLongClick = { onLongClick(MenuTarget.Song(item.song)) }, + ), ) } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt new file mode 100644 index 00000000..d0971b62 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt @@ -0,0 +1,134 @@ +package com.subtracks.data.repo + +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.Song +import com.subtracks.data.prefs.fakeUserPreferences +import com.subtracks.data.source.StarType +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LibraryRepositoryTest { + private lateinit var db: SubtracksDatabase + private lateinit var sourceRepository: SourceRepository + private lateinit var repository: LibraryRepository + private val messages = ArrayList() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences()) + repository = LibraryRepository(db, sourceRepository, showMessage = { messages += it }) + } + + @After + fun tearDown() { + sourceRepository.close() + db.close() + } + + @Test + fun starringASongPostsToTheServerAndUpdatesTheRow() = + runBlocking { + withSource { server -> + server.enqueue(ok()) + + assertTrue(repository.setStar(StarType.Song, "s1", true).isSuccess) + + val request = server.takeRequest() + assertEquals("/rest/star.view", request.requestUrl!!.encodedPath) + assertEquals("s1", request.requestUrl!!.queryParameter("id")) + assertNotNull(starred()) + } + } + + @Test + fun unstarringPostsToTheServerAndClearsTheRow() = + runBlocking { + withSource { server -> + server.enqueue(ok()) + repository.setStar(StarType.Song, "s1", true) + server.enqueue(ok()) + + repository.setStar(StarType.Song, "s1", false) + + server.takeRequest() + assertEquals("/rest/unstar.view", server.takeRequest().requestUrl!!.encodedPath) + assertNull(starred()) + } + } + + @Test + fun aRejectedStarLeavesTheRowUntouchedAndReports() = + runBlocking { + withSource { server -> + server.enqueue(failed(40)) + + assertTrue(repository.setStar(StarType.Song, "s1", true).isFailure) + + assertNull(starred()) + assertEquals(listOf("Could not update star"), messages) + } + } + + private suspend fun withSource(block: suspend (MockWebServer) -> Unit) { + val server = MockWebServer() + server.start() + try { + val sourceId = sourceRepository.addSource("nav", server.url("/").toString(), "u", "p", false) + db.libraryDao().upsertSongs(listOf(seedSong(sourceId))) + block(server) + } finally { + server.shutdown() + } + } + + private suspend fun starred(): Long? = + repository + .albumSongs(sourceRepository.activeSourceIdOnce()!!, "al1") + .first() + .first { it.id == "s1" } + .starred + + private fun seedSong(sourceId: Long) = + Song( + sourceId = sourceId, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Track", + album = "Album", + artist = "Artist", + duration = 1_000, + track = 1, + disc = 1, + starred = null, + genre = null, + ) + + private fun ok() = MockResponse().setBody("") + + private fun failed(code: Int) = + MockResponse().setBody("") +} diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 7bc8e629..e122c0d6 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -16,6 +16,7 @@ import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song import com.subtracks.data.model.Source import com.subtracks.data.source.MusicSource +import com.subtracks.data.source.StarType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -679,6 +680,12 @@ private class FakeMusicSource( override suspend fun ping() = Unit + override suspend fun setStar( + type: StarType, + id: String, + starred: Boolean, + ) = Unit + override fun artists(): Flow> = batches(artists) override fun albums(): Flow> = batches(albums)