feat: implement blacklist functionality for tracks and artists with UI updates

This commit is contained in:
Kingkor Roy Tirtho 2026-07-22 16:23:02 +06:00
parent 27e591d8a3
commit 3448e16ed2
33 changed files with 1463 additions and 127 deletions

View File

@ -45,6 +45,7 @@ actual val platformModules = module {
collectionPlaybackHelper = get(),
audioPlayerQueue = get(),
settingsRepository = get(),
blacklistRepository = get(),
)
}
}

View File

@ -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)
}

View File

@ -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<AudioPlayerInterface>().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<Media3MediaItem> = withContext(Dispatchers.Main) {
val count = audioPlayer.player.mediaItemCount
val items = mutableListOf<Media3MediaItem>()
for (i in 0 until count) {
val item = audioPlayer.player.getMediaItemAt(i)
if (item != null) {
private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> =
withContext(Dispatchers.Main) {
val count = audioPlayer.player.mediaItemCount
val items = mutableListOf<Media3MediaItem>()
for (i in 0 until count) {
val item = audioPlayer.player.getMediaItemAt(i)
items.add(item)
}
items
}
items
}
companion object {
private const val TAG = "PlaybackService"

View File

@ -102,6 +102,9 @@
<string name="settings_error_whole_number">Enter a valid whole number.</string>
<string name="settings_error_port_range">Port must be between 1 and 65535.</string>
<string name="settings_blacklist_title">Blacklist</string>
<string name="settings_blacklist_subtitle">Manage blacklisted tracks and artists</string>
<string name="settings_desktop_minimize_title">Minimize to tray</string>
<string name="settings_desktop_minimize_subtitle">Keep Spotube running in the system tray when the window is minimized.</string>
<string name="settings_desktop_discord_title">Discord rich presence</string>

View File

@ -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<DeviceAudioPlayerQueue>()
@ -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 = "")
}

View File

@ -55,6 +55,12 @@ class Database(val paths: Paths) {
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() }
)
}
val blacklistDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
)
}
}
object DatabaseKeys {

View File

@ -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<QueueStateRepository>() }
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get())
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
}
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }

View File

@ -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<Routes.Playlist> {
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<Routes.Artist> {
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<Routes.Album> {
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<Routes.Lyrics> {
LyricsScreen(viewModel = koinViewModel())
}
navigation<Routes.SavedTracks> {
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<Routes.Blacklist> {
BlacklistScreen()
}
}

View File

@ -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())
}
}
}

View File

@ -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<String>,
blacklistedArtistIds: Set<String>,
): 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

View File

@ -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<NavigationCommands>()
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,
)
}

View File

@ -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(

View File

@ -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) },
),

View File

@ -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),
) {

View File

@ -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<AlbumViewModel>(
key = albumId,
parameters = { parametersOf(albumId) }
)
val navigationCommands = koinInject<NavigationCommands>()
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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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) {

View File

@ -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<AlbumViewModel>()
@ -93,6 +97,12 @@ class AlbumViewModel(
val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _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<MetadataTrack>) {
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<MetadataTrack>) {
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

View File

@ -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<ArtistViewModel>(
key = artistId,
parameters = { parametersOf(artistId) }
)
val navigationCommands = koinInject<NavigationCommands>()
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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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",
)
}
}
}
}

View File

@ -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<ArtistViewModel>()
@ -73,6 +77,12 @@ class ArtistViewModel(
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _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<MetadataTrack>) {
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<MetadataTrack>) {
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<QueueEntry> {
private suspend fun resolveTopTrackEntries(): List<QueueEntry> {
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 {

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<MetadataTrack>> = database.blacklistDataStore.data
.map { prefs ->
val tracksJson = prefs[BLACKLISTED_TRACKS_KEY]
if (tracksJson != null) {
try {
json.decodeFromString<List<MetadataTrack>>(tracksJson)
} catch (e: Exception) {
emptyList()
}
} else {
emptyList()
}
}
open val blacklistedArtists: Flow<List<MetadataArtist>> = database.blacklistDataStore.data
.map { prefs ->
val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY]
if (artistsJson != null) {
try {
json.decodeFromString<List<MetadataArtist>>(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<MetadataTrack> {
return database.blacklistDataStore.data.map { prefs ->
val tracksJson = prefs[BLACKLISTED_TRACKS_KEY]
if (tracksJson != null) {
try {
json.decodeFromString<List<MetadataTrack>>(tracksJson)
} catch (e: Exception) {
emptyList()
}
} else {
emptyList()
}
}.first()
}
suspend fun getArtistsSnapshot(): List<MetadataArtist> {
return database.blacklistDataStore.data.map { prefs ->
val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY]
if (artistsJson != null) {
try {
json.decodeFromString<List<MetadataArtist>>(artistsJson)
} catch (e: Exception) {
emptyList()
}
} else {
emptyList()
}
}.first()
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<BlacklistViewModel>()
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,
)
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<MetadataTrack>> = repository.blacklistedTracks
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val blacklistedArtists: StateFlow<List<MetadataArtist>> = 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)
}
}
}

View File

@ -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<PlaylistViewModel>(
key = playlistId,
parameters = { parametersOf(playlistId) }
)
val navigationCommands = koinInject<NavigationCommands>()
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

View File

@ -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<PlaylistViewModel>()
@ -97,6 +101,12 @@ class PlaylistViewModel(
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _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<MetadataTrack>) {
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<MetadataTrack>) {
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)
}
}

View File

@ -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<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() }
)
val navigationCommands = koinInject<NavigationCommands>()
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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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) {

View File

@ -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<SavedTracksViewModel>()
@ -92,6 +96,12 @@ class SavedTracksViewModel(
val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _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<MetadataTrack>) {
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<MetadataTrack>) {
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)
}
}

View File

@ -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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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<MetadataTrack>) {
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<MetadataTrack>) {
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<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> 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<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> 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,
)

View File

@ -105,6 +105,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
playbackSection(
settings = settingsState!!,
settingsViewModel = settingsViewModel,
navigatorCommands = navigatorCommands,
)
if (settingsState != null)
cacheSection(

View File

@ -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)
},
)
},
)
)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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<List<MetadataTrack>>(emptyList())
override val blacklistedTracks: Flow<List<MetadataTrack>> = _blacklistedTracks.asStateFlow()
private val _blacklistedArtists = MutableStateFlow<List<MetadataArtist>>(emptyList())
override val blacklistedArtists: Flow<List<MetadataArtist>> = _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
}
}