feat(blacklist): add grayout styling for blacklisted artist tracks

- TrackRow: Add isBlacklisted prop with grayout styling and ban icon
- VirtualizedTrackList: Check blacklist status for each track
- PlaylistDetailView: Gray out and restrict actions for blacklisted tracks
- AlbumDetailView: Apply album-level blacklist check to all tracks
- HomeView: Gray out blacklisted tracks in Continue Listening section
- ArtistDetailView: Banner shown when viewing blacklisted artist page

Blacklisted tracks show reduced opacity, ban icon instead of track
number, and restricted menu actions (navigation only, no playback).
This commit is contained in:
vicrodh committed 2026-02-02 14:17:01 -06:00
1 parent bdffe6c709
commit 6ff6269f74
6 files changed
+136 -24

No files matched your search

+35 -7
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Play, Pause, Heart, HardDrive, AlertCircle } from 'lucide-svelte';
import { Play, Pause, Heart, HardDrive, AlertCircle, Ban } from 'lucide-svelte';
import TrackMenu from './TrackMenu.svelte';
import DownloadButton from './DownloadButton.svelte';
import {
@@ -26,6 +26,7 @@
isLocal?: boolean; // Whether this is a local library track
isUnavailable?: boolean; // Track removed from Qobuz or otherwise unavailable
unavailableTooltip?: string; // Tooltip for unavailable indicator
isBlacklisted?: boolean; // Artist is blacklisted
isFavoriteOverride?: boolean; // Optional override for favorite state
downloadStatus?: OfflineCacheStatus;
downloadProgress?: number;
@@ -73,6 +74,7 @@
isUnavailable = false,
unavailableTooltip,
isFavoriteOverride,
isBlacklisted = false,
downloadStatus = 'none',
downloadProgress = 0,
hideDownload = false,
@@ -136,19 +138,24 @@
<div
class="track-row"
class:playing={isPlaying}
class:hovered={isHovered && !isPlaying}
class:hovered={isHovered && !isPlaying && !isBlacklisted}
class:compact
class:blacklisted={isBlacklisted}
data-track-id={trackId ?? undefined}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
onclick={onPlay}
onclick={isBlacklisted ? undefined : onPlay}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onPlay?.()}
tabindex={isBlacklisted ? -1 : 0}
onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && onPlay?.()}
>
<!-- Track Number / Play Button / Unavailable Indicator -->
<div class="track-number" class:unavailable={isUnavailable}>
{#if isUnavailable}
<div class="track-number" class:unavailable={isUnavailable} class:blacklisted={isBlacklisted}>
{#if isBlacklisted}
<span class="blacklisted-icon" title="Artist is blacklisted">
<Ban size={14} />
</span>
{:else if isUnavailable}
<span class="unavailable-icon" title={unavailableTooltip}>
<AlertCircle size={16} />
</span>
@@ -334,6 +341,27 @@
cursor: help;
}
/* Blacklisted track styles */
.track-row.blacklisted {
opacity: 0.4;
cursor: not-allowed;
}
.track-row.blacklisted:hover {
background: transparent;
}
.track-number.blacklisted {
color: var(--text-muted);
}
.blacklisted-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.track-number :global(.play-icon) {
color: white;
}
+13 -5
View File
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import TrackRow from './TrackRow.svelte';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import { isArtistBlacklisted } from '$lib/stores/blacklistStore';
// Use generic types to match whatever caller passes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -315,6 +316,7 @@
{@const albumId = getAlbumId?.(item.track)}
{@const downloadInfo = getOfflineCacheStatus?.(trackId) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = downloadInfo.status === 'ready'}
{@const trackBlacklisted = !isLocal && artistId ? isArtistBlacklisted(artistId) : false}
<TrackRow
trackId={trackId}
number={getTrackNumber(item.track, item.index)}
@@ -324,18 +326,24 @@
duration={formatDuration(getTrackDuration(item.track))}
quality={getQualityBadge(item.track)}
isPlaying={isPlaybackActive && activeTrackId === trackId}
isBlacklisted={trackBlacklisted}
{isLocal}
{hideDownload}
{hideFavorite}
hideDownload={hideDownload || trackBlacklisted}
hideFavorite={hideFavorite || trackBlacklisted}
isFavoriteOverride={isFavoriteOverride}
downloadStatus={downloadInfo.status}
downloadProgress={downloadInfo.progress}
onArtistClick={trackArtist && onArtistClick ? () => onArtistClick(trackArtist) : undefined}
onAlbumClick={albumKey && onAlbumClick ? () => onAlbumClick(item.track) : undefined}
onPlay={() => onTrackPlay(item.track)}
onDownload={onDownload ? () => onDownload(item.track) : undefined}
onPlay={trackBlacklisted ? undefined : () => onTrackPlay(item.track)}
onDownload={onDownload && !trackBlacklisted ? () => onDownload(item.track) : undefined}
onRemoveDownload={onRemoveDownload ? () => onRemoveDownload(trackId) : undefined}
menuActions={{
menuActions={trackBlacklisted ? {
// Only navigation actions for blacklisted tracks
onGoToAlbum: albumId && onGoToAlbum ? () => onGoToAlbum(albumId) : undefined,
onGoToArtist: artistId && onGoToArtist ? () => onGoToArtist(artistId) : undefined,
onShowInfo: !isLocal && onShowInfo ? () => onShowInfo(trackId) : undefined,
} : {
onPlayNow: () => onTrackPlay(item.track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(item.track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(item.track) : undefined,
@@ -13,6 +13,7 @@
loadAlbumFavorites,
toggleAlbumFavorite
} from '$lib/stores/albumFavoritesStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
interface Track {
id: number;
@@ -413,6 +414,7 @@
<!-- Track Rows -->
<div class="tracks">
{@const albumArtistBlacklisted = album.artistId ? isArtistBlacklisted(album.artistId) : false}
{#each album.tracks as track, trackIndex (`${track.id}-${downloadStateVersion}`)}
{@const downloadInfo = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = downloadInfo.status === 'ready'}
@@ -424,14 +426,21 @@
duration={track.duration}
quality={track.quality}
isPlaying={activeTrackId === track.id}
isBlacklisted={albumArtistBlacklisted}
downloadStatus={downloadInfo.status}
downloadProgress={downloadInfo.progress}
onPlay={() => {
hideFavorite={albumArtistBlacklisted}
hideDownload={albumArtistBlacklisted}
onPlay={albumArtistBlacklisted ? undefined : () => {
onTrackPlay?.(track);
}}
onDownload={onTrackDownload ? () => onTrackDownload(track) : undefined}
onRemoveDownload={onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
menuActions={{
onDownload={!albumArtistBlacklisted && onTrackDownload ? () => onTrackDownload(track) : undefined}
onRemoveDownload={!albumArtistBlacklisted && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
menuActions={albumArtistBlacklisted ? {
// Blacklisted: only navigation and info
onGoToArtist: album.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(album.artistId!) : undefined,
onShowInfo: onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined
} : {
onPlayNow: () => {
onTrackPlay?.(track);
},
@@ -1317,6 +1317,17 @@
</div>
</div>
<!-- Blacklist Warning Banner -->
{#if artistIsBlacklisted}
<div class="blacklist-banner">
<Ban size={18} />
<span>This artist is blacklisted. Their music is hidden from search, radio, and suggestions.</span>
<button class="unblock-btn" onclick={toggleBlacklist} disabled={isBlacklistLoading}>
Unblock
</button>
</div>
{/if}
{#if showJumpNav}
<div class="jump-nav">
<div class="jump-nav-left">
@@ -2614,6 +2625,44 @@
cursor: not-allowed;
}
.blacklist-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
margin: 0 24px 16px 24px;
color: #ef4444;
font-size: 13px;
}
.blacklist-banner span {
flex: 1;
}
.unblock-btn {
padding: 6px 14px;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 6px;
color: #ef4444;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.unblock-btn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.3);
}
.unblock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.floating-message {
position: absolute;
left: 50%;
+12 -2
View File
@@ -9,6 +9,7 @@
import HomeSettingsModal from '../HomeSettingsModal.svelte';
import GenreFilterButton from '../GenreFilterButton.svelte';
import { formatDuration, formatQuality, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import { isArtistBlacklisted } from '$lib/stores/blacklistStore';
import {
subscribe as subscribeHomeSettings,
getSettings,
@@ -907,6 +908,7 @@
{@const isActiveTrack = isPlaybackActive && activeTrackId === track.id}
{@const cacheStatus = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none', progress: 0 }}
{@const isTrackDownloaded = cacheStatus.status === 'ready'}
{@const trackBlacklisted = track.artistId ? isArtistBlacklisted(track.artistId) : false}
<TrackRow
trackId={track.id}
number={index + 1}
@@ -916,13 +918,21 @@
duration={track.duration}
quality={getTrackQuality(track)}
isPlaying={isActiveTrack}
isBlacklisted={trackBlacklisted}
compact={true}
hideDownload={trackBlacklisted}
hideFavorite={trackBlacklisted}
downloadStatus={cacheStatus.status}
downloadProgress={cacheStatus.progress}
onArtistClick={track.artistId && onArtistClick ? () => onArtistClick(track.artistId!) : undefined}
onAlbumClick={track.albumId && onAlbumClick ? () => onAlbumClick(track.albumId!) : undefined}
onPlay={() => handleContinueTrackPlay(track, index)}
menuActions={{
onPlay={trackBlacklisted ? undefined : () => handleContinueTrackPlay(track, index)}
menuActions={trackBlacklisted ? {
// Only navigation actions for blacklisted tracks
onGoToAlbum: track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId!) : undefined,
onGoToArtist: track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId!) : undefined,
onShowInfo: onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined
} : {
onPlayNow: () => handleContinueTrackPlay(track, index),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
@@ -19,6 +19,7 @@
} from '$lib/stores/offlineStore';
import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isTrackUnavailable, clearTrackUnavailable, subscribe as subscribeUnavailable } from '$lib/stores/unavailableTracksStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import { showToast } from '$lib/stores/toastStore';
import { t } from '$lib/i18n';
import { onMount, tick } from 'svelte';
@@ -1809,6 +1810,7 @@
</button>
</div>
{/if}
{@const trackBlacklisted = !track.isLocal && track.artistId ? isArtistBlacklisted(track.artistId) : false}
<TrackRow
trackId={track.isLocal ? undefined : track.id}
number={idx + 1}
@@ -1825,18 +1827,24 @@
isLocal={track.isLocal}
isUnavailable={removedFromQobuz && isOwnPlaylist}
unavailableTooltip={removedFromQobuz ? $t('player.trackUnavailable') : undefined}
hideFavorite={track.isLocal || removedFromQobuz}
hideDownload={track.isLocal || removedFromQobuz}
isBlacklisted={trackBlacklisted}
hideFavorite={track.isLocal || removedFromQobuz || trackBlacklisted}
hideDownload={track.isLocal || removedFromQobuz || trackBlacklisted}
downloadStatus={downloadInfo.status}
downloadProgress={downloadInfo.progress}
onPlay={available ? () => handleTrackClick(track, idx) : undefined}
onDownload={available && !track.isLocal && onTrackDownload ? () => onTrackDownload(track) : undefined}
onRemoveDownload={available && !track.isLocal && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
onPlay={available && !trackBlacklisted ? () => handleTrackClick(track, idx) : undefined}
onDownload={available && !track.isLocal && !trackBlacklisted && onTrackDownload ? () => onTrackDownload(track) : undefined}
onRemoveDownload={available && !track.isLocal && !trackBlacklisted && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
menuActions={removedFromQobuz ? (isOwnPlaylist ? {
// Only allow remove from playlist and find replacement for tracks removed from Qobuz (owned playlists only)
onRemoveFromPlaylist: () => removeTrackFromPlaylist(track),
onFindReplacement: () => openReplacementModal(track)
} : {}) : available ? {
} : {}) : trackBlacklisted ? {
// Blacklisted: only allow navigation and info, no playback
onGoToAlbum: !track.isLocal && track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId!) : undefined,
onGoToArtist: !track.isLocal && track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId!) : undefined,
onShowInfo: !track.isLocal && onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined
} : available ? {
onPlayNow: () => handleTrackClick(track, idx),
onPlayNext: track.isLocal ? () => handleTrackPlayNext(track) : (onTrackPlayNext ? () => onTrackPlayNext(track) : undefined),
onPlayLater: track.isLocal ? () => handleTrackPlayLater(track) : (onTrackPlayLater ? () => onTrackPlayLater(track) : undefined),