Files
qbz/src/lib/components/AlbumCard.svelte
T
vicrodh 9de28b8480 fix: quality badge contrast in Light theme and artist image upload parameter
- Quality badge now has dark background with white text in Light theme
- Fixed parameter name in artist image upload (customImagePath)
- Improved visibility on both AlbumCard and QualityBadge components

cp
2026-01-15 11:48:09 -06:00

452 lines
11 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { Play, Disc3, Heart } from 'lucide-svelte';
import AlbumMenu from './AlbumMenu.svelte';
import {
subscribe as subscribeAlbumFavorites,
isAlbumFavorite,
loadAlbumFavorites,
toggleAlbumFavorite
} from '$lib/stores/albumFavoritesStore';
interface Props {
albumId?: string;
artwork: string;
title: string;
artist: string;
quality?: string;
size?: 'standard' | 'large';
onclick?: () => void;
onPlay?: () => void;
onPlayNext?: () => void;
onPlayLater?: () => void;
onShareQobuz?: () => void;
onShareSonglink?: () => void;
onDownload?: () => void;
showFavorite?: boolean;
favoriteEnabled?: boolean;
isAlbumFullyDownloaded?: boolean;
onOpenContainingFolder?: () => void;
onReDownloadAlbum?: () => void;
downloadStateVersion?: number;
}
let {
albumId,
artwork,
title,
artist,
quality,
size = 'standard',
onclick,
onPlay,
onPlayNext,
onPlayLater,
onShareQobuz,
onShareSonglink,
onDownload,
showFavorite,
favoriteEnabled,
isAlbumFullyDownloaded = false,
onOpenContainingFolder,
onReDownloadAlbum,
downloadStateVersion
}: Props = $props();
const isDownloaded = $derived.by(() => {
void downloadStateVersion;
return isAlbumFullyDownloaded;
});
let imageError = $state(false);
const cardSize = $derived(size === 'large' ? 180 : 162);
let titleRef: HTMLDivElement | null = $state(null);
let titleTextRef: HTMLSpanElement | null = $state(null);
let titleOverflow = $state(0);
let artistRef: HTMLDivElement | null = $state(null);
let artistTextRef: HTMLSpanElement | null = $state(null);
let artistOverflow = $state(0);
const titleOffset = $derived(titleOverflow > 0 ? `-${titleOverflow + 16}px` : '0px');
const artistOffset = $derived(artistOverflow > 0 ? `-${artistOverflow + 16}px` : '0px');
const tickerSpeed = 80;
const titleDuration = $derived(titleOverflow > 0 ? `${(titleOverflow + 16) / tickerSpeed}s` : '0s');
const artistDuration = $derived(artistOverflow > 0 ? `${(artistOverflow + 16) / tickerSpeed}s` : '0s');
let favoriteFromStore = $state(false);
const isFavorite = $derived(albumId ? favoriteFromStore : false);
const hasMenu = $derived(!!(onPlayNext || onPlayLater || onShareQobuz || onShareSonglink || onDownload));
const showFavoriteButton = $derived(showFavorite ?? !!albumId);
const favoriteAvailable = $derived(favoriteEnabled ?? !!albumId);
const hasOverlay = $derived(!!(showFavoriteButton || onPlay || hasMenu));
let menuOpen = $state(false);
function handleImageError() {
imageError = true;
}
async function handleToggleFavorite(event: MouseEvent) {
event.stopPropagation();
if (!albumId || !favoriteAvailable) return;
await toggleAlbumFavorite(albumId);
}
function handlePlay(event: MouseEvent) {
event.stopPropagation();
onPlay?.();
}
function isOverlayAction(target: EventTarget | null) {
return target instanceof HTMLElement && !!target.closest('.action-buttons');
}
function handleCardClick(event: MouseEvent) {
if (isOverlayAction(event.target)) return;
onclick?.();
}
function updateOverflow() {
if (titleRef && titleTextRef) {
const overflow = titleTextRef.scrollWidth - titleRef.clientWidth;
titleOverflow = overflow > 0 ? overflow : 0;
}
if (artistRef && artistTextRef) {
const overflow = artistTextRef.scrollWidth - artistRef.clientWidth;
artistOverflow = overflow > 0 ? overflow : 0;
}
}
onMount(() => {
updateOverflow();
const observer = new ResizeObserver(() => updateOverflow());
if (titleRef) {
observer.observe(titleRef);
}
if (artistRef) {
observer.observe(artistRef);
}
if (albumId) {
void loadAlbumFavorites();
favoriteFromStore = isAlbumFavorite(albumId);
const unsubscribe = subscribeAlbumFavorites(() => {
favoriteFromStore = isAlbumFavorite(albumId);
});
return () => {
observer.disconnect();
unsubscribe();
};
}
return () => observer.disconnect();
});
$effect(() => {
title;
tick().then(updateOverflow);
});
$effect(() => {
artist;
tick().then(updateOverflow);
});
</script>
<div
class="album-card"
style="width: {cardSize}px"
onclick={handleCardClick}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<!-- Artwork Container -->
<div
class="artwork-container"
style="width: {cardSize}px; height: {cardSize}px"
>
{#if imageError || !artwork}
<div class="artwork-placeholder">
<Disc3 size={48} />
</div>
{:else}
<img src={artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
{/if}
<!-- Action Overlay -->
{#if hasOverlay}
<div class="action-overlay" class:menu-open={menuOpen}>
<div class="action-buttons">
{#if showFavoriteButton}
<button
class="overlay-btn overlay-btn--minor"
class:is-active={isFavorite}
class:disabled={!favoriteAvailable}
type="button"
aria-disabled={!favoriteAvailable}
disabled={!favoriteAvailable}
onclick={handleToggleFavorite}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart size={18} fill={isFavorite ? 'white' : 'none'} color="white" />
</button>
{/if}
{#if onPlay}
<button class="overlay-btn" type="button" onclick={handlePlay} title="Play">
<Play size={18} fill="white" color="white" />
</button>
{/if}
{#if hasMenu}
<div class="overlay-menu">
<AlbumMenu
onPlayNext={onPlayNext}
onPlayLater={onPlayLater}
onShareQobuz={onShareQobuz}
onShareSonglink={onShareSonglink}
onDownload={onDownload}
isAlbumFullyDownloaded={isDownloaded}
onOpenContainingFolder={onOpenContainingFolder}
onReDownloadAlbum={onReDownloadAlbum}
onOpenChange={(open) => (menuOpen = open)}
/>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Text Info -->
<div class="info">
<div
class="title"
class:scrollable={titleOverflow > 0}
style="--ticker-offset: {titleOffset}; --ticker-duration: {titleDuration};"
bind:this={titleRef}
>
<span class="title-text" bind:this={titleTextRef}>{title}</span>
</div>
<div
class="artist"
class:scrollable={artistOverflow > 0}
style="--ticker-offset: {artistOffset}; --ticker-duration: {artistDuration};"
bind:this={artistRef}
>
<span class="artist-text" bind:this={artistTextRef}>{artist}</span>
</div>
{#if quality}
<div class="quality-badge">{quality}</div>
{/if}
</div>
</div>
<style>
.album-card {
flex-shrink: 0;
cursor: pointer;
transition: transform 150ms ease;
}
.artwork-container {
position: relative;
margin-bottom: 8px;
border-radius: 8px;
}
.artwork-container img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-muted);
border-radius: inherit;
}
.quality-badge {
display: inline-block;
margin-top: 4px;
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 2px 6px;
}
[data-theme="light"] .quality-badge {
color: #ffffff;
background: rgba(40, 42, 54, 0.85);
border: 1px solid rgba(40, 42, 54, 0.95);
}
.play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease;
}
.action-overlay {
position: absolute;
inset: -1px;
display: flex;
align-items: flex-start;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease;
background: rgba(10, 10, 10, 0.25);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
pointer-events: auto;
border-radius: inherit;
}
.album-card:hover .action-overlay,
.action-overlay:focus-within,
.action-overlay.menu-open {
opacity: 1;
}
.action-buttons {
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
position: absolute;
left: 50%;
top: 75%;
transform: translate(-50%, -50%);
}
.overlay-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.85);
background: transparent;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 150ms ease, background-color 150ms ease;
}
.overlay-btn:hover {
border-color: var(--accent-primary);
}
.overlay-btn.is-active {
background-color: rgba(255, 255, 255, 0.2);
}
.overlay-btn.disabled,
.overlay-btn:disabled {
opacity: 0.5;
cursor: default;
transform: none;
}
.overlay-menu {
display: flex;
align-items: center;
justify-content: center;
}
.overlay-btn--minor {
width: 30px;
height: 30px;
}
:global(.album-card .album-menu) {
display: flex;
align-items: center;
}
:global(.album-card .album-menu .menu-trigger) {
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.85);
background: transparent;
color: white;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
:global(.album-card .album-menu .menu-trigger:hover) {
border-color: var(--accent-primary);
}
.info {
width: 100%;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 2px;
}
.title.scrollable {
text-overflow: clip;
}
.title-text {
display: inline-block;
white-space: nowrap;
}
.album-card:hover .title.scrollable .title-text {
animation: title-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
@keyframes title-ticker {
from { transform: translateX(0); }
to { transform: translateX(var(--ticker-offset)); }
}
.artist {
font-size: 13px;
font-weight: 400;
color: var(--text-muted);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist.scrollable {
text-overflow: clip;
}
.artist-text {
display: inline-block;
white-space: nowrap;
}
.album-card:hover .artist.scrollable .artist-text {
animation: title-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
</style>