feat(blacklist): add frontend store and artist page UI

Phase 3 (minimal) - UI for testing:
- Add artistBlacklistStore.ts with O(1) local checks
- Initialize store on app startup
- Add blacklist toggle button to ArtistDetailView
- Button shows red when artist is blacklisted

cc
This commit is contained in:
vicrodh committed 2026-02-02 13:18:38 -06:00
1 parent bd4c9b7792
commit e1849f0bb3
3 files changed
+235 -2

No files matched your search

@@ -1,6 +1,12 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc } from 'lucide-svelte';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc, Ban } from 'lucide-svelte';
import {
isBlacklisted,
addToBlacklist,
removeFromBlacklist,
subscribe as subscribeBlacklist
} from '$lib/stores/artistBlacklistStore';
import type { ArtistDetail, QobuzArtist } from '$lib/types';
import AlbumCard from '../AlbumCard.svelte';
import TrackMenu from '../TrackMenu.svelte';
@@ -121,19 +127,33 @@
let isFavorite = $state(false);
let isFavoriteLoading = $state(false);
let isRadioLoading = $state(false);
let artistIsBlacklisted = $state(false);
let isBlacklistLoading = $state(false);
let radioLoadingMessage = $state('');
let radioJustCreated = $state(false);
let showNetworkSidebar = $state(false);
let unsubscribeSidebar: (() => void) | null = null;
let unsubscribeBlacklist: (() => void) | null = null;
function updateBlacklistState() {
artistIsBlacklisted = isBlacklisted(artist.id);
}
onMount(() => {
unsubscribeSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
showNetworkSidebar = active === 'network';
});
// Initialize blacklist state and subscribe to changes
updateBlacklistState();
unsubscribeBlacklist = subscribeBlacklist(() => {
updateBlacklistState();
});
});
onDestroy(() => {
unsubscribeSidebar?.();
unsubscribeBlacklist?.();
});
let similarArtists = $state<QobuzArtist[]>([]);
let similarArtistsLoading = $state(false);
@@ -387,6 +407,28 @@
}
}
async function toggleBlacklist() {
if (isBlacklistLoading) return;
isBlacklistLoading = true;
const wasBlacklisted = artistIsBlacklisted;
try {
if (wasBlacklisted) {
await removeFromBlacklist(artist.id);
artistIsBlacklisted = false;
} else {
await addToBlacklist(artist.id, artist.name);
artistIsBlacklisted = true;
}
} catch (err) {
console.error('Failed to toggle artist blacklist:', err);
artistIsBlacklisted = wasBlacklisted; // Rollback on error
} finally {
isBlacklistLoading = false;
}
}
async function createArtistRadio() {
if (isRadioLoading) return;
@@ -1258,6 +1300,15 @@
>
<img src="/element-connect.svg" alt="Network" class="network-icon" />
</button>
<button
class="blacklist-btn"
class:is-blacklisted={artistIsBlacklisted}
onclick={toggleBlacklist}
disabled={isBlacklistLoading}
title={artistIsBlacklisted ? 'Remove from blacklist' : 'Add to blacklist'}
>
<Ban size={24} />
</button>
</div>
</div>
</div>
@@ -2529,6 +2580,36 @@
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
}
.blacklist-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-tertiary);
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
transition: all 150ms ease;
flex-shrink: 0;
}
.blacklist-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--error);
}
.blacklist-btn.is-blacklisted {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
.blacklist-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.floating-message {
position: absolute;
left: 50%;
+150
View File
@@ -0,0 +1,150 @@
/**
* Artist Blacklist Store
*
* Manages the local artist blacklist for filtering unwanted artists
* from search results, radio, and suggestions.
*/
import { invoke } from '@tauri-apps/api/core';
// ============ Types ============
export interface BlacklistedArtist {
artist_id: number;
artist_name: string;
added_at: number;
notes: string | null;
}
export interface BlacklistSettings {
enabled: boolean;
}
// ============ State ============
let blacklist: BlacklistedArtist[] = [];
let enabled: boolean = true;
// In-memory Set for O(1) lookups
let blacklistIds: Set<number> = new Set();
const listeners = new Set<() => void>();
function notifyListeners(): void {
for (const listener of listeners) {
listener();
}
}
function updateIdSet(): void {
blacklistIds = new Set(blacklist.map(a => a.artist_id));
}
// ============ Public API ============
/**
* Load blacklist from backend
*/
export async function loadBlacklist(): Promise<BlacklistedArtist[]> {
const [list, settings] = await Promise.all([
invoke<BlacklistedArtist[]>('get_artist_blacklist'),
invoke<BlacklistSettings>('get_blacklist_settings')
]);
blacklist = list;
enabled = settings.enabled;
updateIdSet();
notifyListeners();
return list;
}
/**
* Add an artist to the blacklist
*/
export async function addToBlacklist(
artistId: number,
artistName: string,
notes?: string
): Promise<void> {
await invoke('add_to_artist_blacklist', {
artistId,
artistName,
notes: notes ?? null
});
// Reload to get the full entry with timestamp
await loadBlacklist();
}
/**
* Remove an artist from the blacklist
*/
export async function removeFromBlacklist(artistId: number): Promise<void> {
await invoke('remove_from_artist_blacklist', { artistId });
// Update local state
blacklist = blacklist.filter(a => a.artist_id !== artistId);
updateIdSet();
notifyListeners();
}
/**
* Check if an artist is blacklisted (O(1) local check)
*/
export function isBlacklisted(artistId: number): boolean {
if (!enabled) return false;
return blacklistIds.has(artistId);
}
/**
* Set the enabled state
*/
export async function setEnabled(value: boolean): Promise<void> {
await invoke('set_blacklist_enabled', { enabled: value });
enabled = value;
notifyListeners();
}
/**
* Check if blacklist is enabled
*/
export function isEnabled(): boolean {
return enabled;
}
/**
* Get cached blacklist (no backend call)
*/
export function getCachedBlacklist(): BlacklistedArtist[] {
return blacklist;
}
/**
* Get blacklist count
*/
export function getCount(): number {
return blacklist.length;
}
/**
* Clear all blacklisted artists
*/
export async function clearBlacklist(): Promise<void> {
await invoke('clear_artist_blacklist');
blacklist = [];
updateIdSet();
notifyListeners();
}
/**
* Subscribe to blacklist changes
*/
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* Initialize blacklist store (call on app startup)
*/
export async function initBlacklistStore(): Promise<void> {
await loadBlacklist();
}
+3 -1
View File
@@ -37,11 +37,12 @@
getCurrentContext,
requestContextTrackFocus
} from '$lib/stores/playbackContextStore';
import {
import {
initPlaybackPreferences,
getCachedPreferences,
isAutoplayEnabled
} from '$lib/stores/playbackPreferencesStore';
import { initBlacklistStore } from '$lib/stores/artistBlacklistStore';
// UI state management
import {
@@ -2430,6 +2431,7 @@
// Initialize playback context and preferences stores
initPlaybackContextStore();
initPlaybackPreferences();
initBlacklistStore();
// Load infinite play preference
try {