From 3448e16ed27e719eba1c920975ff0844df434141 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 22 Jul 2026 16:23:02 +0600 Subject: [PATCH] feat: implement blacklist functionality for tracks and artists with UI updates --- .../spotube/core/di/Modules.android.kt | 1 + .../spotube/media/MediaBrowseHelper.kt | 15 +- .../krtirtho/spotube/media/PlaybackService.kt | 36 +- .../composeResources/values/strings.xml | 3 + .../audioplayer/DeviceAudioPlayerQueue.kt | 19 +- .../dev/krtirtho/spotube/core/db/Database.kt | 6 + .../dev/krtirtho/spotube/core/di/Modules.kt | 32 +- .../core/navigation/NavigationModule.kt | 76 ++++- .../core/navigation/NavigationState.kt | 1 + .../core/playback/CollectionPlaybackHelper.kt | 37 +- .../spotube/core/ui/component/ArtistCard.kt | 6 +- .../spotube/core/ui/component/TrackList.kt | 1 + .../spotube/core/ui/component/TrackOptions.kt | 3 +- .../core/ui/component/cards/AvatarCard.kt | 10 + .../spotube/modules/album/AlbumScreen.kt | 38 ++- .../spotube/modules/album/AlbumViewModel.kt | 50 ++- .../spotube/modules/artist/ArtistScreen.kt | 59 +++- .../spotube/modules/artist/ArtistViewModel.kt | 80 ++++- .../modules/blacklist/BlacklistRepository.kt | 128 +++++++ .../modules/blacklist/BlacklistScreen.kt | 321 ++++++++++++++++++ .../modules/blacklist/BlacklistViewModel.kt | 50 +++ .../modules/playlist/PlaylistScreen.kt | 36 +- .../modules/playlist/PlaylistViewModel.kt | 50 ++- .../modules/saved_tracks/SavedTracksScreen.kt | 36 +- .../saved_tracks/SavedTracksViewModel.kt | 50 ++- .../spotube/modules/search/SearchScreen.kt | 76 ++++- .../modules/settings/SettingsScreen.kt | 1 + .../settings/sections/PlaybackSection.kt | 30 ++ .../resources/iconsax/IconsaxForbidden.kt | 74 ++++ .../iconsax/IconsaxMusicSquareRemoveFilled.kt | 129 +++++++ .../resources/iconsax/IconsaxUserRemove.kt | 98 ++++++ .../audioplayer/DeviceAudioPlayerQueueTest.kt | 4 +- .../audioplayer/FakeBlacklistRepository.kt | 34 ++ 33 files changed, 1463 insertions(+), 127 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt index 836db5d6..1ee43348 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt @@ -45,6 +45,7 @@ actual val platformModules = module { collectionPlaybackHelper = get(), audioPlayerQueue = get(), settingsRepository = get(), + blacklistRepository = get(), ) } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt index 43036c8c..cd37edbc 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt @@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.album.AlbumRepository +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.home.HomeScreenRepository import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository @@ -52,6 +53,7 @@ class MediaBrowseHelper( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val settingsRepository: SettingsRepository, + private val blacklistRepository: BlacklistRepository, ) { companion object { @@ -532,7 +534,18 @@ class MediaBrowseHelper( } catch (_: Exception) { return } - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + + val blacklistedTracks = blacklistRepository.getTracksSnapshot() + val blacklistedArtists = blacklistRepository.getArtistsSnapshot() + val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet() + val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } if (entries.isNotEmpty()) { audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0) } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt index a19c40fc..6db62be6 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt @@ -42,6 +42,7 @@ import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import dev.krtirtho.spotube.MainActivity import dev.krtirtho.spotube.core.audioplayer.AudioPlayer +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import kotlinx.coroutines.CoroutineScope @@ -55,7 +56,7 @@ import org.koin.core.component.inject @OptIn(UnstableApi::class) class PlaybackService : MediaLibraryService(), KoinComponent { - private val audioPlayer: AudioPlayer by inject() + private val audioPlayer = inject().value as AudioPlayer private val audioPlayerQueue: AudioPlayerQueue by inject() private val mediaBrowseHelper: MediaBrowseHelper by inject() private lateinit var librarySession: MediaLibrarySession @@ -87,9 +88,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent { startForeground(NOTIFICATION_ID, notification) - librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) - .setSessionActivity(sessionActivity) - .build() + librarySession = + MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) + .setSessionActivity(sessionActivity) + .build() addSession(librarySession) @@ -112,7 +114,8 @@ class PlaybackService : MediaLibraryService(), KoinComponent { } } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = librarySession + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = + librarySession override fun onDestroy() { Log.i(TAG, "onDestroy") @@ -270,7 +273,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent { } else { results } - Log.d(TAG, "onGetSearchResult: query=$query, page=$page, returning ${paged.size} items") + Log.d( + TAG, + "onGetSearchResult: query=$query, page=$page, returning ${paged.size} items" + ) future.set(LibraryResult.ofItemList(paged, params)) } catch (e: Exception) { Log.e(TAG, "onGetSearchResult failed for $query", e) @@ -352,17 +358,20 @@ class PlaybackService : MediaLibraryService(), KoinComponent { helper.playPlaylist(playlistId) return } + mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> { val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playAlbum(albumId) return } + mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> { val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playSavedTracks() return } + mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ARTIST_TRACKS}:") -> { mediaBrowseHelper.resolveAndPlayFromMediaId(mediaId) return @@ -384,17 +393,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent { } } - private suspend fun buildCurrentMediaItemList(): List = withContext(Dispatchers.Main) { - val count = audioPlayer.player.mediaItemCount - val items = mutableListOf() - for (i in 0 until count) { - val item = audioPlayer.player.getMediaItemAt(i) - if (item != null) { + private suspend fun buildCurrentMediaItemList(): List = + withContext(Dispatchers.Main) { + val count = audioPlayer.player.mediaItemCount + val items = mutableListOf() + for (i in 0 until count) { + val item = audioPlayer.player.getMediaItemAt(i) items.add(item) } + items } - items - } companion object { private const val TAG = "PlaybackService" diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c0eae33c..535fa90e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -102,6 +102,9 @@ Enter a valid whole number. Port must be between 1 and 65535. + Blacklist + Manage blacklisted tracks and artists + Minimize to tray Keep Spotube running in the system tray when the window is minimized. Discord rich presence diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index f20e70a0..2ae7e046 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,7 @@ class DeviceAudioPlayerQueue( private val settingsProvider: SettingsProvider, private val repository: QueueStateRepository, private val pluginProvider: PluginProvider, + private val blacklistRepository: BlacklistRepository, ) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() @@ -346,7 +348,22 @@ class DeviceAudioPlayerQueue( return } - val newEntries = recommendations.map { track -> + val blacklistedTrackIds = blacklistRepository.blacklistedTracks.first().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.blacklistedArtists.first().map { it.id }.toSet() + + val filteredRecommendations = recommendations.filter { track -> + val trackBlacklisted = track.id in blacklistedTrackIds + val artistBlacklisted = track.artists.any { it.id in blacklistedArtistIds } + !trackBlacklisted && !artistBlacklisted + } + + if (filteredRecommendations.isEmpty()) { + logger.w { "Endless playback: all recommendations were blacklisted" } + isFetchingRecommendations = false + return + } + + val newEntries = filteredRecommendations.map { track -> QueueEntry.StreamingTrack(track = track, url = "") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt index ae3c51ed..f268d57f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt @@ -55,6 +55,12 @@ class Database(val paths: Paths) { produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() } ) } + + val blacklistDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() } + ) + } } object DatabaseKeys { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 1d2408ed..c244d038 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -35,6 +35,8 @@ import dev.krtirtho.spotube.modules.album.AlbumRepository import dev.krtirtho.spotube.modules.album.AlbumViewModel import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.artist.ArtistViewModel +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository +import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.home.HomeScreenRepository @@ -125,12 +127,20 @@ val sharedModules = module { libraryRepository = get(), playbackHelper = get(), audioPlayerQueue = get(), + blacklistRepository = get(), ) } // Saved Tracks singleOf(::SavedTracksRepository) - viewModelOf(::SavedTracksViewModel) + viewModel { + SavedTracksViewModel( + repository = get(), + playbackHelper = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + ) + } // Artist singleOf(::ArtistRepository) @@ -141,9 +151,14 @@ val sharedModules = module { libraryRepository = get(), savedTracksRepository = get(), audioPlayerQueue = get(), + blacklistRepository = get(), ) } + // Blacklist + singleOf(::BlacklistRepository) + viewModelOf(::BlacklistViewModel) + // Album singleOf(::AlbumRepository) viewModel { (albumId: String) -> @@ -152,8 +167,9 @@ val sharedModules = module { repository = get(), savedTracksRepository = get(), playbackHelper = get(), - audioPlayerQueue = get(), libraryRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), ) } @@ -161,7 +177,15 @@ val sharedModules = module { viewModelOf(::LyricsViewModel) // Local playback proxy server - singleOf(::CollectionPlaybackHelper) + single { + CollectionPlaybackHelper( + albumRepository = get(), + playlistRepository = get(), + savedTracksRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + ) + } singleOf(::MatchedTracksRepository) singleOf(::StreamingUrlRepository) singleOf(::AlternativeTracksRepository) @@ -170,7 +194,7 @@ val sharedModules = module { } singleOf(::AudioPlayerQueueRepository) { bind() } single { - DeviceAudioPlayerQueue(get(), get(), get(), get()) + DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) } single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index ca77a3c9..5d4c8054 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -20,11 +20,14 @@ package dev.krtirtho.spotube.core.navigation import androidx.navigation3.runtime.NavKey import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.album.AlbumScreen +import dev.krtirtho.spotube.modules.artist.ArtistViewModel +import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.playlist.PlaylistScreen import dev.krtirtho.spotube.modules.plugin.PluginScreen +import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen @@ -33,6 +36,7 @@ import kotlinx.serialization.Serializable import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.module.dsl.singleOf +import org.koin.core.parameter.parametersOf import org.koin.dsl.module import org.koin.dsl.navigation3.navigation @@ -54,22 +58,25 @@ sealed interface Routes : NavKey { data object Plugins : Routes @Serializable - data object WebView: Routes + data object WebView : Routes @Serializable - data object Lyrics: Routes + data object Lyrics : Routes @Serializable - data class Playlist(val playlistId: String): Routes + data class Playlist(val playlistId: String) : Routes @Serializable - data class Artist(val artistId: String): Routes + data class Artist(val artistId: String) : Routes @Serializable - data class Album(val albumId: String): Routes + data class Album(val albumId: String) : Routes @Serializable - data object SavedTracks: Routes + data object SavedTracks : Routes + + @Serializable + data object Blacklist : Routes } @OptIn(KoinExperimentalAPI::class) @@ -94,19 +101,68 @@ val navigationModule = module { WebViewScreen(get()) } navigation { - PlaylistScreen(it.playlistId) + PlaylistScreen( + playlistId = it.playlistId, + viewModel = koinViewModel( + key = it.playlistId, + parameters = { parametersOf(it.playlistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + shareService = get(), + downloadsViewModel = get(), + libraryRepository = get(), + navigationCommands = get(), + ) } navigation { - ArtistScreen(it.artistId) + ArtistScreen( + viewModel = koinViewModel( + key = it.artistId, + parameters = { parametersOf(it.artistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + shareService = get(), + downloadsViewModel = get(), + libraryRepository = get(), + navigationCommands = get(), + ) } navigation { - AlbumScreen(it.albumId) + AlbumScreen( + albumId = it.albumId, + viewModel = koinViewModel( + key = it.albumId, + parameters = { parametersOf(it.albumId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + shareService = get(), + downloadsViewModel = get(), + libraryRepository = get(), + navigationCommands = get(), + ) } navigation { LyricsScreen(viewModel = koinViewModel()) } navigation { - SavedTracksScreen() + SavedTracksScreen( + viewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + shareService = get(), + downloadsViewModel = get(), + libraryRepository = get(), + navigationCommands = get(), + ) + } + navigation { + BlacklistScreen() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt index e37d1ed0..7ef24dec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt @@ -60,6 +60,7 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Playlist::class, Routes.Playlist.serializer()) subclass(Routes.Artist::class, Routes.Artist.serializer()) subclass(Routes.Album::class, Routes.Album.serializer()) + subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index c6d12df5..81594eda 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.album.AlbumRepository +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository @@ -30,6 +31,7 @@ class CollectionPlaybackHelper( private val playlistRepository: PlaylistRepository, private val savedTracksRepository: SavedTracksRepository, private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, ) { suspend fun playAlbum(albumId: String) { if (audioPlayerQueue.isAlbumPlaying(albumId)) return @@ -185,7 +187,13 @@ class CollectionPlaybackHelper( pagination?.items?.let { allTracks.addAll(it) } } - return allTracks.map { track -> + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = allTracks.filter { track -> + !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) + } + return filteredTracks.map { track -> QueueEntry.StreamingTrack(track = track, url = "") } } @@ -200,7 +208,16 @@ class CollectionPlaybackHelper( pagination?.items?.let { allTracks.addAll(it) } } - return allTracks.map { track -> + val blacklistedTracks = blacklistRepository.getTracksSnapshot() + val blacklistedArtists = blacklistRepository.getArtistsSnapshot() + val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet() + val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet() + + val filteredTracks = allTracks.filter { track -> + !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) + } + + return filteredTracks.map { track -> QueueEntry.StreamingTrack(track = track, url = "") } } @@ -215,11 +232,25 @@ class CollectionPlaybackHelper( pagination?.items?.let { allTracks.addAll(it) } } - return allTracks.map { track -> + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = allTracks.filter { track -> + !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) + } + return filteredTracks.map { track -> QueueEntry.StreamingTrack(track = track, url = "") } } + private fun isTrackBlacklisted( + track: MetadataTrack, + blacklistedTrackIds: Set, + blacklistedArtistIds: Set, + ): Boolean { + return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds } + } + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { if (id.isNotBlank() && other.id.isNotBlank()) { return id == other.id diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt index 2459d276..4cb5922e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.ui.component +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -34,6 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Alignment import androidx.compose.ui.text.style.TextAlign @@ -50,17 +52,19 @@ import org.koin.compose.koinInject fun ArtistCard( artist: MetadataArtist.Basic, modifier: Modifier = Modifier, + isBlacklisted: Boolean = false, ) { val navigationCommands = koinInject() AvatarCard( title = artist.name, - subtitle = "Artist", + subtitle = if (isBlacklisted) "Blacklisted" else "Artist", imageURL = artist.thumbnails.firstOrNull()?.url, onClick = { navigationCommands.navigateTo(Routes.Artist(artist.id)) }, modifier = modifier, + isBlacklisted = isBlacklisted, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index b897e9b0..8c75b3f1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -571,6 +571,7 @@ private fun TrackListRow( isSelected -> rowTheme.background.selected isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered isCurrentTrack -> rowTheme.background.focused + trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f)) else -> rowTheme.background.normal } val highlightColor = Color.White.copy( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index 82b40e24..05a31564 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -51,6 +51,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxShare @@ -294,7 +295,7 @@ private fun buildTrackMenuItems( add( AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicSquareRemove, + icon = Iconsax.IconsaxMusicSquareRemoveFilled, label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }, ), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt index 797fd961..940b6b3d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.ui.component.cards +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,6 +38,7 @@ 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.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -56,10 +58,18 @@ fun AvatarCard( imageURL: String? = null, onClick: () -> Unit = {}, modifier: Modifier = Modifier, + isBlacklisted: Boolean = false, ) { + val backgroundColor = if (isBlacklisted) { + MaterialTheme.colorScheme.error.copy(alpha = 0.1f) + } else { + Color.Transparent + } + Box( modifier = modifier .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) .clickable(onClick = { onClick.invoke() }, enabled = true) .width(160.dp), ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index d7be2b4d..8895dae7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayer +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry @@ -36,6 +37,7 @@ import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsState +import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -45,18 +47,18 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable -fun AlbumScreen(albumId: String) { - val audioPlayerQueue: AudioPlayerQueue = koinInject() - val audioPlayer: AudioPlayer = koinInject() - val shareService: ShareService = koinInject() - val downloadsViewModel: DownloadsViewModel = koinViewModel() - val libraryRepository: LibraryRepository = koinInject() +fun AlbumScreen( + albumId: String, + viewModel: AlbumViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + shareService: ShareService, + downloadsViewModel: DownloadsViewModel, + libraryRepository: LibraryRepository, + navigationCommands: NavigationCommands +) { + val scope = rememberCoroutineScope() - val viewModel = koinViewModel( - key = albumId, - parameters = { parametersOf(albumId) } - ) - val navigationCommands = koinInject() val state by viewModel.uiState.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() @@ -64,6 +66,8 @@ fun AlbumScreen(albumId: String) { val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() + val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle() + val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) } var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } var currentUserId by remember { mutableStateOf(null) } @@ -77,12 +81,12 @@ fun AlbumScreen(albumId: String) { val queueTrackIds = queue.mapNotNull { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id }.toSet() - return TrackOptionsState( - isInQueue = queueTrackIds.contains(track.id), - isCurrentlyPlaying = track.id == currentTrackId, - isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = false, - ) + return TrackOptionsState( + isInQueue = queueTrackIds.contains(track.id), + isCurrentlyPlaying = track.id == currentTrackId, + isFavorite = savedTrackIds.contains(track.id), + isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }, + ) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index 2e93d290..23ac0683 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -27,6 +27,7 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -36,6 +37,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -85,6 +88,7 @@ class AlbumViewModel( private val libraryRepository: LibraryRepository, private val playbackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -93,6 +97,12 @@ class AlbumViewModel( val savedAlbumIds get() = libraryRepository.savedAlbumIdsFlow + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + init { viewModelScope.launch { repository.pluginManager.selectedMetadataPlugin @@ -103,6 +113,12 @@ class AlbumViewModel( loadInitialData() } } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) } private suspend fun loadInitialData() = runCatching { @@ -220,7 +236,7 @@ class AlbumViewModel( } is TrackOptionsAction.Download -> {} - is TrackOptionsAction.ToggleBlacklist -> {} + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.Share -> {} is TrackOptionsAction.AddToPlaylist -> {} } @@ -229,18 +245,46 @@ class AlbumViewModel( fun addTracksToQueue(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllToQueue(entries) } } fun playTracksNext(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllAfterCurrent(entries) } } + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + val savedTrackIds get() = savedTracksRepository.savedTracksIdsFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 8384d03e..0a024f95 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -64,7 +64,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry @@ -85,36 +85,30 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf import kotlin.math.roundToInt @Composable -fun ArtistScreen(artistId: String) { - val audioPlayerQueue: AudioPlayerQueue = koinInject() - val audioPlayer: AudioPlayer = koinInject() - val shareService: ShareService = koinInject() - val downloadsViewModel: DownloadsViewModel = koinViewModel() - val libraryRepository: LibraryRepository = koinInject() +fun ArtistScreen( + viewModel: ArtistViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + shareService: ShareService, + downloadsViewModel: DownloadsViewModel, + libraryRepository: LibraryRepository, + navigationCommands: NavigationCommands +) { val scope = rememberCoroutineScope() - val viewModel = koinViewModel( - key = artistId, - parameters = { parametersOf(artistId) } - ) - val navigationCommands = koinInject() val state by viewModel.state.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() @@ -122,6 +116,8 @@ fun ArtistScreen(artistId: String) { val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() + val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle() + val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) } var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } var currentUserId by remember { mutableStateOf(null) } @@ -139,7 +135,7 @@ fun ArtistScreen(artistId: String) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = false, + isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }, ) } @@ -191,6 +187,8 @@ fun ArtistScreen(artistId: String) { artist = currentState.artist, isSaved = savedArtistIds.contains(currentState.artist.id), onFollowClick = viewModel::toggleSavedArtist, + isBlacklisted = currentState.artist.id in blacklistedArtistIds, + onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }, ) } @@ -398,6 +396,8 @@ private fun ArtistHeaderCard( artist: MetadataArtist.Detailed, isSaved: Boolean, onFollowClick: () -> Unit, + isBlacklisted: Boolean, + onBlacklistClick: () -> Unit, ) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val isCompact = maxWidth < 600.dp @@ -432,6 +432,8 @@ private fun ArtistHeaderCard( ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, + isBlacklisted = isBlacklisted, + onBlacklistClick = onBlacklistClick, ) } } else { @@ -459,6 +461,8 @@ private fun ArtistHeaderCard( ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, + isBlacklisted = isBlacklisted, + onBlacklistClick = onBlacklistClick, ) } } @@ -547,6 +551,8 @@ private fun ArtistMeta( private fun ArtistHeaderActions( isSaved: Boolean, onFollowClick: () -> Unit, + isBlacklisted: Boolean, + onBlacklistClick: () -> Unit, ) { Row( horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -561,6 +567,23 @@ private fun ArtistHeaderActions( Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } + + if (isBlacklisted) { + SecondaryIconButton(onClick = onBlacklistClick) { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = "Remove from blacklist", + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + SecondaryIconButton(onClick = onBlacklistClick) { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = "Add to blacklist", + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index d838d5c6..f9d2f81a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -28,11 +28,14 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -61,6 +64,7 @@ class ArtistViewModel( private val libraryRepository: LibraryRepository, private val savedTracksRepository: SavedTracksRepository, private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -73,6 +77,12 @@ class ArtistViewModel( val savedTrackIds get() = savedTracksRepository.savedTracksIdsFlow + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + init { viewModelScope.launch { libraryRepository.savedArtistIdsFlow.collect { @@ -82,6 +92,12 @@ class ArtistViewModel( } } } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) loadOverview() } @@ -101,6 +117,28 @@ class ArtistViewModel( } } + fun isArtistBlacklisted(artistId: String): Boolean { + return _blacklistedArtistIds.value.contains(artistId) + } + + fun toggleArtistBlacklist(artist: MetadataArtist) { + viewModelScope.launch { + blacklistRepository.toggleArtist(artist) + } + } + + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + fun loadMoreAlbums() { val currentState = _state.value if (currentState !is ArtistScreenState.Loaded) return @@ -211,17 +249,32 @@ class ArtistViewModel( ) } } - fun addTracksToQueue(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllToQueue(entries) } } fun playTracksNext(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllAfterCurrent(entries) } } @@ -267,7 +320,7 @@ class ArtistViewModel( } } is TrackOptionsAction.Download -> {} - is TrackOptionsAction.ToggleBlacklist -> {} + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.Share -> {} is TrackOptionsAction.AddToPlaylist -> {} } @@ -303,12 +356,23 @@ class ArtistViewModel( } } - private fun resolveTopTrackEntries(): List { + private suspend fun resolveTopTrackEntries(): List { val currentState = _state.value if (currentState !is ArtistScreenState.Loaded) return emptyList() - return currentState.topTracks.map { track -> - QueueEntry.StreamingTrack(track = track, url = "") - } + + val blacklistedTracks = blacklistRepository.getTracksSnapshot() + val blacklistedArtists = blacklistRepository.getArtistsSnapshot() + val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet() + val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet() + + return currentState.topTracks + .filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + .map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } } private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt new file mode 100644 index 00000000..ee9f4d56 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.blacklist + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.db.Database +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString + +open class BlacklistRepository( + private val database: Database, +) { + companion object { + private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks") + private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists") + } + + private val json = Json { ignoreUnknownKeys = true } + + open val blacklistedTracks: Flow> = database.blacklistDataStore.data + .map { prefs -> + val tracksJson = prefs[BLACKLISTED_TRACKS_KEY] + if (tracksJson != null) { + try { + json.decodeFromString>(tracksJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + open val blacklistedArtists: Flow> = database.blacklistDataStore.data + .map { prefs -> + val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY] + if (artistsJson != null) { + try { + json.decodeFromString>(artistsJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + open suspend fun toggleTrack(track: MetadataTrack) { + val currentTracks = getTracksSnapshot().toMutableList() + val existingIndex = currentTracks.indexOfFirst { it.id == track.id } + + if (existingIndex >= 0) { + currentTracks.removeAt(existingIndex) + } else { + currentTracks.add(track) + } + + database.blacklistDataStore.edit { prefs -> + prefs[BLACKLISTED_TRACKS_KEY] = json.encodeToString(currentTracks) + } + } + + open suspend fun toggleArtist(artist: MetadataArtist) { + val currentArtists = getArtistsSnapshot().toMutableList() + val existingIndex = currentArtists.indexOfFirst { it.id == artist.id } + + if (existingIndex >= 0) { + currentArtists.removeAt(existingIndex) + } else { + currentArtists.add(artist) + } + + database.blacklistDataStore.edit { prefs -> + prefs[BLACKLISTED_ARTISTS_KEY] = json.encodeToString(currentArtists) + } + } + + suspend fun getTracksSnapshot(): List { + return database.blacklistDataStore.data.map { prefs -> + val tracksJson = prefs[BLACKLISTED_TRACKS_KEY] + if (tracksJson != null) { + try { + json.decodeFromString>(tracksJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + }.first() + } + + suspend fun getArtistsSnapshot(): List { + return database.blacklistDataStore.data.map { prefs -> + val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY] + if (artistsJson != null) { + try { + json.decodeFromString>(artistsJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + }.first() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt new file mode 100644 index 00000000..087e02ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.blacklist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.misc.shimmerApply +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled +import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove +import dev.krtirtho.spotube.resources.iconsax.User +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun BlacklistScreen() { + val viewModel = koinViewModel() + val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle() + val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + var filterQuery by rememberSaveable { mutableStateOf("") } + val normalizedQuery = remember(filterQuery) { filterQuery.trim().lowercase() } + + val filteredTracks = remember(blacklistedTracks, normalizedQuery) { + if (normalizedQuery.isBlank()) { + blacklistedTracks + } else { + blacklistedTracks.filter { track -> + val searchableText = buildString { + append(track.title).append(' ') + append(track.album?.title.orEmpty()).append(' ') + append(track.artists.joinToString(" ") { it.name }) + }.lowercase() + searchableText.contains(normalizedQuery) + } + } + } + + val filteredArtists = remember(blacklistedArtists, normalizedQuery) { + if (normalizedQuery.isBlank()) { + blacklistedArtists + } else { + blacklistedArtists.filter { artist -> + artist.name.lowercase().contains(normalizedQuery) + } + } + } + + val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty() + val hasActiveFilter = normalizedQuery.isNotBlank() + + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Blacklist") }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextField( + value = filterQuery, + onValueChange = { filterQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Filter blacklist...") }, + singleLine = true, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search", + ) + }, + ) + + if (isEmpty) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (hasActiveFilter) { + "No blacklisted items match '$filterQuery'" + } else { + "No blacklisted items" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + bottom = 16.dp + shellBottomInset, + ), + ) { + if (filteredTracks.isNotEmpty()) { + item { + Text( + text = "Tracks", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + items(filteredTracks, key = { "track_${it.id}" }) { track -> + BlacklistedTrackRow( + track = track, + onRemove = { viewModel.toggleTrack(track) }, + ) + } + } + + if (filteredArtists.isNotEmpty()) { + item { + Text( + text = "Artists", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + items(filteredArtists, key = { "artist_${it.id}" }) { artist -> + BlacklistedArtistRow( + artist = artist, + onRemove = { viewModel.toggleArtist(artist) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun BlacklistedTrackRow( + track: MetadataTrack, + onRemove: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxMusicCircle, + contentDescription = "Track", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + AsyncImage( + model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.small) + .shimmerApply(), + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + GhostIconButton(onClick = onRemove) { + Icon( + imageVector = Iconsax.IconsaxMusicSquareRemoveFilled, + contentDescription = "Remove from blacklist", + tint = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun BlacklistedArtistRow( + artist: MetadataArtist, + onRemove: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.User, + contentDescription = "Artist", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + val imageUrl = artist.thumbnails.firstOrNull()?.url + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = artist.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .shimmerApply(), + ) + } else { + Icon( + imageVector = Iconsax.User, + contentDescription = artist.name, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = artist.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Artist", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + GhostIconButton(onClick = onRemove) { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = "Remove from blacklist", + tint = MaterialTheme.colorScheme.error, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt new file mode 100644 index 00000000..d4315086 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.blacklist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class BlacklistViewModel( + private val repository: BlacklistRepository, +) : ViewModel() { + + val blacklistedTracks: StateFlow> = repository.blacklistedTracks + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val blacklistedArtists: StateFlow> = repository.blacklistedArtists + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun toggleTrack(track: MetadataTrack) { + viewModelScope.launch { + repository.toggleTrack(track) + } + } + + fun toggleArtist(artist: MetadataArtist) { + viewModelScope.launch { + repository.toggleArtist(artist) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 92f18783..a80cb5b8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayer +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry @@ -57,18 +58,17 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable -fun PlaylistScreen(playlistId: String) { - val audioPlayerQueue: AudioPlayerQueue = koinInject() - val audioPlayer: AudioPlayer = koinInject() - val shareService: ShareService = koinInject() - val downloadsViewModel: DownloadsViewModel = koinViewModel() - val libraryRepository: LibraryRepository = koinInject() +fun PlaylistScreen( + playlistId: String, + viewModel: PlaylistViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + shareService: ShareService, + downloadsViewModel: DownloadsViewModel, + libraryRepository: LibraryRepository, + navigationCommands: NavigationCommands +) { val scope = rememberCoroutineScope() - val viewModel = koinViewModel( - key = playlistId, - parameters = { parametersOf(playlistId) } - ) - val navigationCommands = koinInject() val state by viewModel.uiState.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() @@ -109,18 +109,20 @@ fun PlaylistScreen(playlistId: String) { playerState == PlayerState.PLAYING val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() + val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle() + val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val queueTrackIds = queue.mapNotNull { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id }.toSet() - return TrackOptionsState( - isInQueue = queueTrackIds.contains(track.id), - isCurrentlyPlaying = track.id == currentTrackId, - isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = false, - ) + return TrackOptionsState( + isInQueue = queueTrackIds.contains(track.id), + isCurrentlyPlaying = track.id == currentTrackId, + isFavorite = savedTrackIds.contains(track.id), + isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }, + ) } val errorMessage = (state as? PlaylistScreenState.Error)?.message diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 6fc31b94..0567a1f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -28,6 +28,7 @@ import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsState +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,6 +38,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -86,6 +89,7 @@ class PlaylistViewModel( private val savedTracksRepository: SavedTracksRepository, private val playbackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -97,6 +101,12 @@ class PlaylistViewModel( private val _currentUserId = MutableStateFlow(null) val currentUserId: StateFlow = _currentUserId.asStateFlow() + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + init { viewModelScope.launch { repository.pluginManager.selectedMetadataPlugin @@ -108,6 +118,12 @@ class PlaylistViewModel( loadCurrentUser() } } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) } private suspend fun loadCurrentUser() { @@ -265,23 +281,51 @@ class PlaylistViewModel( } is TrackOptionsAction.Download -> {} - is TrackOptionsAction.ToggleBlacklist -> {} + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.Share -> {} is TrackOptionsAction.AddToPlaylist -> {} } } } + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + fun addTracksToQueue(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllToQueue(entries) } } fun playTracksNext(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllAfterCurrent(entries) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 954755a2..bf6fa1d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayer +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry @@ -40,6 +41,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker +import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -48,23 +50,23 @@ import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.liked_tracks @Composable -fun SavedTracksScreen() { - val audioPlayerQueue: AudioPlayerQueue = koinInject() - val audioPlayer: AudioPlayer = koinInject() - val shareService: ShareService = koinInject() - val downloadsViewModel: DownloadsViewModel = koinViewModel() - val libraryRepository: LibraryRepository = koinInject() +fun SavedTracksScreen( + viewModel: SavedTracksViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + shareService: ShareService, + downloadsViewModel: DownloadsViewModel, + libraryRepository: LibraryRepository, + navigationCommands: NavigationCommands +) { val scope = rememberCoroutineScope() - val viewModel = koinViewModel( - key = SAVED_TRACKS_COLLECTION_ID, - parameters = { parametersOf() } - ) - val navigationCommands = koinInject() val state by viewModel.uiState.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() + val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle() + val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) } var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } var currentUserId by remember { mutableStateOf(null) } @@ -78,12 +80,12 @@ fun SavedTracksScreen() { val queueTrackIds = queue.mapNotNull { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id }.toSet() - return TrackOptionsState( - isInQueue = queueTrackIds.contains(track.id), - isCurrentlyPlaying = track.id == currentTrackId, - isFavorite = true, - isBlacklisted = false, - ) + return TrackOptionsState( + isInQueue = queueTrackIds.contains(track.id), + isCurrentlyPlaying = track.id == currentTrackId, + isFavorite = true, + isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }, + ) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 2dd7661c..0a203772 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -35,6 +35,7 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -43,6 +44,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.core.component.KoinComponent @@ -84,6 +87,7 @@ class SavedTracksViewModel( private val repository: SavedTracksRepository, private val playbackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -92,6 +96,12 @@ class SavedTracksViewModel( val savedTrackIdsFlow: StateFlow> get() = repository.savedTracksIdsFlow + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + init { viewModelScope.launch { combine( @@ -104,6 +114,12 @@ class SavedTracksViewModel( loadInitialData() } } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) } private suspend fun loadInitialData() { @@ -195,23 +211,51 @@ class SavedTracksViewModel( is TrackOptionsAction.ToggleFavorite -> {} is TrackOptionsAction.Download -> {} - is TrackOptionsAction.ToggleBlacklist -> {} + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.Share -> {} is TrackOptionsAction.AddToPlaylist -> {} } } } + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + fun addTracksToQueue(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllToQueue(entries) } } fun playTracksNext(tracks: List) { viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllAfterCurrent(entries) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index d2bdd63c..a83e10b9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -84,6 +84,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.ChipTab @@ -98,6 +100,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.UserCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -120,6 +123,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val shareService: ShareService = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() val libraryRepository: LibraryRepository = koinInject() + val blacklistRepository: BlacklistRepository = koinInject() + val navigationCommands: NavigationCommands = koinInject() val state by viewModel.state.collectAsStateWithLifecycle() val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() @@ -128,6 +133,21 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } var currentUserId by remember { mutableStateOf(null) } + val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList()) + val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList()) + + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistedTracks.map { it.id }.toSet() + val artistIds = blacklistedArtists.map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + scope.launch { + blacklistRepository.toggleTrack(track) + } + } + LaunchedEffect(Unit) { currentUserId = libraryRepository.currentUser()?.id } @@ -180,7 +200,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id) is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track) - is TrackOptionsAction.ToggleBlacklist -> {} + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.Share -> { val uri = track.externalUri?.takeIf { it.isNotBlank() } if (uri != null) { @@ -206,7 +226,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = false, + isBlacklisted = isTrackBlacklisted(track), ) } @@ -222,20 +242,36 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = false, + isBlacklisted = isTrackBlacklisted(track), ) } fun bulkAddToQueue(tracks: List) { scope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllToQueue(entries) } } fun bulkPlayNext(tracks: List) { scope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } audioPlayerQueue.addAllAfterCurrent(entries) } } @@ -315,6 +351,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onArtistClick = { artist -> + navigationCommands.navigateTo(Routes.Artist(artist.id)) + }, + onAlbumClick = { album -> + navigationCommands.navigateTo(Routes.Album(album.id)) + }, + onArtistsOverflowClick = { track -> + // No-op for now, could show a dialog with all artists + }, modifier = Modifier .fillMaxSize(), ) @@ -335,6 +380,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onArtistClick = { artist -> + navigationCommands.navigateTo(Routes.Artist(artist.id)) + }, + onAlbumClick = { album -> + navigationCommands.navigateTo(Routes.Album(album.id)) + }, + onArtistsOverflowClick = { track -> + // No-op for now, could show a dialog with all artists + }, modifier = Modifier .fillMaxSize(), ) @@ -607,6 +661,9 @@ private fun SearchAllTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onArtistsOverflowClick: (MetadataTrack) -> Unit, modifier: Modifier = Modifier, ) { if (query.isBlank()) { @@ -661,6 +718,9 @@ private fun SearchAllTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = onArtistsOverflowClick, contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), headerContent = { if (showTracks) { @@ -745,6 +805,9 @@ private fun SearchTracksTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onArtistsOverflowClick: (MetadataTrack) -> Unit, modifier: Modifier = Modifier, ) { if (query.isBlank()) { @@ -771,6 +834,9 @@ private fun SearchTracksTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = onArtistsOverflowClick, simplified = true, modifier = modifier, ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index b393075d..e420009a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -105,6 +105,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { playbackSection( settings = settingsState!!, settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, ) if (settingsState != null) cacheSection( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 1ec101f7..4dee2d85 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -18,20 +18,27 @@ package dev.krtirtho.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.Cast import compose.icons.feathericons.Radio import compose.icons.feathericons.Repeat import compose.icons.feathericons.Server import compose.icons.feathericons.Sliders import spotube.composeapp.generated.resources.* +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard +import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow @@ -41,6 +48,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.playbackSection( settings: UserSettings, settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, ) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( @@ -159,6 +167,28 @@ internal fun LazyListScope.playbackSection( } ) }, + { + SettingCardItem( + title = stringResource(Res.string.settings_blacklist_title), + subtitle = stringResource(Res.string.settings_blacklist_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxForbidden, + stringResource(Res.string.settings_blacklist_title), + ) + }, + trailingContent = { + Icon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_blacklist_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { + navigatorCommands.navigateTo(Routes.Blacklist) + }, + ) + }, ) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt new file mode 100644 index 00000000..ec73a525 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt @@ -0,0 +1,74 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxForbidden: ImageVector + get() { + if (_IconsaxForbidden != null) { + return _IconsaxForbidden!! + } + _IconsaxForbidden = ImageVector.Builder( + name = "IconsaxForbidden", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(14.9f, 2f) + horizontalLineTo(9.1f) + curveTo(8.42f, 2f, 7.46f, 2.4f, 6.98f, 2.88f) + lineTo(2.88f, 6.98f) + curveTo(2.4f, 7.46f, 2f, 8.42f, 2f, 9.1f) + verticalLineTo(14.9f) + curveTo(2f, 15.58f, 2.4f, 16.54f, 2.88f, 17.02f) + lineTo(6.98f, 21.12f) + curveTo(7.46f, 21.6f, 8.42f, 22f, 9.1f, 22f) + horizontalLineTo(14.9f) + curveTo(15.58f, 22f, 16.54f, 21.6f, 17.02f, 21.12f) + lineTo(21.12f, 17.02f) + curveTo(21.6f, 16.54f, 22f, 15.58f, 22f, 14.9f) + verticalLineTo(9.1f) + curveTo(22f, 8.42f, 21.6f, 7.46f, 21.12f, 6.98f) + lineTo(17.02f, 2.88f) + curveTo(16.54f, 2.4f, 15.58f, 2f, 14.9f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(3.02f, 21.751f) + curveTo(2.83f, 21.751f, 2.64f, 21.681f, 2.49f, 21.531f) + curveTo(2.2f, 21.241f, 2.2f, 20.761f, 2.49f, 20.471f) + lineTo(20.47f, 2.491f) + curveTo(20.76f, 2.201f, 21.24f, 2.201f, 21.53f, 2.491f) + curveTo(21.82f, 2.781f, 21.82f, 3.261f, 21.53f, 3.551f) + lineTo(3.55f, 21.531f) + curveTo(3.41f, 21.681f, 3.22f, 21.751f, 3.02f, 21.751f) + close() + } + } + }.build() + + return _IconsaxForbidden!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxForbidden: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt new file mode 100644 index 00000000..83baefb4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt @@ -0,0 +1,129 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicSquareRemoveFilled: ImageVector + get() { + if (_IconsaxMusicSquareRemoveFilled != null) { + return _IconsaxMusicSquareRemoveFilled!! + } + _IconsaxMusicSquareRemoveFilled = ImageVector.Builder( + name = "IconsaxMusicSquareRemoveFilled", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(21.97f, 16.33f) + curveTo(21.25f, 15.51f, 20.18f, 15f, 19f, 15f) + curveTo(16.79f, 15f, 15f, 16.79f, 15f, 19f) + curveTo(15f, 19.75f, 15.21f, 20.46f, 15.58f, 21.06f) + curveTo(15.78f, 21.4f, 16.04f, 21.71f, 16.34f, 21.97f) + curveTo(17.04f, 22.61f, 17.97f, 23f, 19f, 23f) + curveTo(20.46f, 23f, 21.73f, 22.22f, 22.42f, 21.06f) + curveTo(22.79f, 20.46f, 23f, 19.75f, 23f, 19f) + curveTo(23f, 17.98f, 22.61f, 17.04f, 21.97f, 16.33f) + close() + moveTo(20.6f, 20.58f) + curveTo(20.45f, 20.73f, 20.26f, 20.8f, 20.07f, 20.8f) + curveTo(19.88f, 20.8f, 19.69f, 20.73f, 19.54f, 20.58f) + lineTo(19.01f, 20.05f) + lineTo(18.46f, 20.6f) + curveTo(18.31f, 20.75f, 18.12f, 20.82f, 17.93f, 20.82f) + curveTo(17.74f, 20.82f, 17.55f, 20.75f, 17.4f, 20.6f) + curveTo(17.11f, 20.31f, 17.11f, 19.83f, 17.4f, 19.54f) + lineTo(17.95f, 18.99f) + lineTo(17.42f, 18.46f) + curveTo(17.13f, 18.17f, 17.13f, 17.69f, 17.42f, 17.4f) + curveTo(17.71f, 17.11f, 18.19f, 17.11f, 18.48f, 17.4f) + lineTo(19.01f, 17.93f) + lineTo(19.51f, 17.43f) + curveTo(19.8f, 17.14f, 20.28f, 17.14f, 20.57f, 17.43f) + curveTo(20.86f, 17.72f, 20.86f, 18.2f, 20.57f, 18.49f) + lineTo(20.07f, 18.99f) + lineTo(20.6f, 19.52f) + curveTo(20.89f, 19.81f, 20.89f, 20.28f, 20.6f, 20.58f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12.23f, 12.07f) + curveTo(11.82f, 12.07f, 11.49f, 12.4f, 11.49f, 12.81f) + curveTo(11.49f, 13.22f, 11.82f, 13.55f, 12.23f, 13.55f) + curveTo(12.64f, 13.55f, 12.97f, 13.22f, 12.97f, 12.81f) + curveTo(12.97f, 12.4f, 12.64f, 12.07f, 12.23f, 12.07f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(6.76f, 13.06f) + curveTo(6.35f, 13.06f, 6.02f, 13.39f, 6.02f, 13.8f) + curveTo(6.02f, 14.21f, 6.35f, 14.54f, 6.76f, 14.54f) + curveTo(7.17f, 14.54f, 7.5f, 14.21f, 7.5f, 13.8f) + curveTo(7.5f, 13.39f, 7.17f, 13.06f, 6.76f, 13.06f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16.19f, 2f) + horizontalLineTo(7.81f) + curveTo(7.53f, 2f, 7.26f, 2.01f, 7f, 2.05f) + curveTo(3.85f, 2.34f, 2f, 4.45f, 2f, 7.81f) + verticalLineTo(16.19f) + curveTo(2f, 19.55f, 3.85f, 21.66f, 7f, 21.95f) + curveTo(7.26f, 21.99f, 7.53f, 22f, 7.81f, 22f) + horizontalLineTo(13.5f) + curveTo(13.89f, 22f, 14.14f, 21.56f, 13.99f, 21.2f) + curveTo(13.7f, 20.51f, 13.5f, 19.71f, 13.5f, 19f) + curveTo(13.5f, 15.97f, 15.97f, 13.5f, 19f, 13.5f) + curveTo(19.76f, 13.5f, 20.5f, 13.65f, 21.18f, 13.95f) + curveTo(21.55f, 14.11f, 22f, 13.86f, 22f, 13.46f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + moveTo(14.48f, 8.05f) + verticalLineTo(12.81f) + curveTo(14.48f, 12.82f, 14.47f, 12.83f, 14.47f, 12.85f) + curveTo(14.45f, 14.07f, 13.46f, 15.06f, 12.23f, 15.06f) + curveTo(10.99f, 15.06f, 9.99f, 14.05f, 9.99f, 12.82f) + curveTo(9.99f, 11.58f, 11f, 10.58f, 12.23f, 10.58f) + curveTo(12.49f, 10.58f, 12.74f, 10.63f, 12.98f, 10.72f) + verticalLineTo(9.03f) + lineTo(9.01f, 10.11f) + verticalLineTo(13.81f) + curveTo(9.01f, 13.82f, 9f, 13.83f, 9f, 13.84f) + curveTo(8.98f, 15.06f, 7.99f, 16.04f, 6.76f, 16.04f) + curveTo(5.52f, 16.04f, 4.52f, 15.03f, 4.52f, 13.8f) + curveTo(4.52f, 12.57f, 5.53f, 11.56f, 6.76f, 11.56f) + curveTo(7.02f, 11.56f, 7.27f, 11.61f, 7.5f, 11.7f) + verticalLineTo(9.54f) + verticalLineTo(7.79f) + curveTo(7.5f, 6.86f, 8.08f, 6.11f, 8.97f, 5.87f) + lineTo(11.95f, 5.05f) + curveTo(12.88f, 4.8f, 13.45f, 5.05f, 13.77f, 5.29f) + curveTo(14.23f, 5.64f, 14.46f, 6.21f, 14.46f, 6.97f) + verticalLineTo(8.05f) + horizontalLineTo(14.48f) + close() + } + } + }.build() + + return _IconsaxMusicSquareRemoveFilled!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicSquareRemoveFilled: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt new file mode 100644 index 00000000..b69a5663 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt @@ -0,0 +1,98 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxUserRemove: ImageVector + get() { + if (_IconsaxUserRemove != null) { + return _IconsaxUserRemove!! + } + _IconsaxUserRemove = ImageVector.Builder( + name = "IconsaxUserRemove", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.09f, 21.5f) + curveTo(21.09f, 21.78f, 20.87f, 22f, 20.59f, 22f) + horizontalLineTo(3.41f) + curveTo(3.13f, 22f, 2.91f, 21.78f, 2.91f, 21.5f) + curveTo(2.91f, 17.36f, 6.99f, 14f, 12f, 14f) + curveTo(13.03f, 14f, 14.03f, 14.14f, 14.95f, 14.41f) + curveTo(14.36f, 15.11f, 14f, 16.02f, 14f, 17f) + curveTo(14f, 17.75f, 14.21f, 18.46f, 14.58f, 19.06f) + curveTo(14.78f, 19.4f, 15.04f, 19.71f, 15.34f, 19.97f) + curveTo(16.04f, 20.61f, 16.97f, 21f, 18f, 21f) + curveTo(19.12f, 21f, 20.13f, 20.54f, 20.85f, 19.8f) + curveTo(21.01f, 20.34f, 21.09f, 20.91f, 21.09f, 21.5f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21.88f, 16.04f) + curveTo(21.78f, 15.65f, 21.62f, 15.26f, 21.4f, 14.91f) + curveTo(21.25f, 14.65f, 21.05f, 14.4f, 20.83f, 14.17f) + curveTo(20.11f, 13.45f, 19.17f, 13.06f, 18.21f, 13.01f) + curveTo(17.12f, 12.94f, 16.01f, 13.34f, 15.17f, 14.17f) + curveTo(14.38f, 14.96f, 13.98f, 16.01f, 14f, 17.06f) + curveTo(14.01f, 18.06f, 14.41f, 19.06f, 15.17f, 19.83f) + curveTo(15.7f, 20.36f, 16.35f, 20.71f, 17.04f, 20.87f) + curveTo(17.42f, 20.97f, 17.82f, 21.01f, 18.22f, 20.98f) + curveTo(19.17f, 20.94f, 20.1f, 20.56f, 20.83f, 19.83f) + curveTo(21.86f, 18.8f, 22.21f, 17.35f, 21.88f, 16.04f) + close() + moveTo(19.6f, 18.6f) + curveTo(19.31f, 18.89f, 18.83f, 18.89f, 18.54f, 18.6f) + lineTo(17.99f, 18.05f) + lineTo(17.46f, 18.58f) + curveTo(17.17f, 18.87f, 16.69f, 18.87f, 16.4f, 18.58f) + curveTo(16.11f, 18.28f, 16.11f, 17.81f, 16.4f, 17.52f) + lineTo(16.93f, 16.99f) + lineTo(16.42f, 16.49f) + curveTo(16.13f, 16.19f, 16.13f, 15.72f, 16.42f, 15.42f) + curveTo(16.72f, 15.13f, 17.19f, 15.13f, 17.49f, 15.42f) + lineTo(17.99f, 15.93f) + lineTo(18.52f, 15.4f) + curveTo(18.81f, 15.11f, 19.28f, 15.11f, 19.58f, 15.4f) + curveTo(19.87f, 15.69f, 19.87f, 16.17f, 19.58f, 16.46f) + lineTo(19.05f, 16.99f) + lineTo(19.6f, 17.54f) + curveTo(19.89f, 17.83f, 19.89f, 18.31f, 19.6f, 18.6f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 12f) + curveTo(14.761f, 12f, 17f, 9.761f, 17f, 7f) + curveTo(17f, 4.239f, 14.761f, 2f, 12f, 2f) + curveTo(9.239f, 2f, 7f, 4.239f, 7f, 7f) + curveTo(7f, 9.761f, 9.239f, 12f, 12f, 12f) + close() + } + } + }.build() + + return _IconsaxUserRemove!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxUserRemove: ImageVector? = null diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index 74fa0327..d0a2aa10 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -28,6 +28,7 @@ class DeviceAudioPlayerQueueTest { private lateinit var fakeSettings: FakeSettingsProvider private lateinit var fakeRepo: FakeAudioPlayerQueueRepository private lateinit var fakePlugin: FakePluginProvider + private lateinit var fakeBlacklist: FakeBlacklistRepository @BeforeTest fun setup() { @@ -40,6 +41,7 @@ class DeviceAudioPlayerQueueTest { fakeSettings = FakeSettingsProvider() fakeRepo = FakeAudioPlayerQueueRepository() fakePlugin = FakePluginProvider() + fakeBlacklist = FakeBlacklistRepository() } @AfterTest @@ -48,7 +50,7 @@ class DeviceAudioPlayerQueueTest { } private fun createQueue(): DeviceAudioPlayerQueue { - return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin) + return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin, fakeBlacklist) } private fun streamingTrack( diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt new file mode 100644 index 00000000..df4eae48 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt @@ -0,0 +1,34 @@ +package dev.krtirtho.spotube.core.audioplayer + +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.db.Database +import dev.krtirtho.spotube.core.paths.Paths +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeBlacklistRepository : BlacklistRepository( + database = Database(Paths()) +) { + private val _blacklistedTracks = MutableStateFlow>(emptyList()) + override val blacklistedTracks: Flow> = _blacklistedTracks.asStateFlow() + + private val _blacklistedArtists = MutableStateFlow>(emptyList()) + override val blacklistedArtists: Flow> = _blacklistedArtists.asStateFlow() + + override suspend fun toggleTrack(track: MetadataTrack) { + val current = _blacklistedTracks.value.toMutableList() + val idx = current.indexOfFirst { it.id == track.id } + if (idx >= 0) current.removeAt(idx) else current.add(track) + _blacklistedTracks.value = current + } + + override suspend fun toggleArtist(artist: MetadataArtist) { + val current = _blacklistedArtists.value.toMutableList() + val idx = current.indexOfFirst { it.id == artist.id } + if (idx >= 0) current.removeAt(idx) else current.add(artist) + _blacklistedArtists.value = current + } +}