import type { SubsonicAlbum } from '../api/subsonicTypes'; import React, { memo, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum'; import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useLocalPlaybackStore } from '../store/localPlaybackStore'; import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers'; import { CoverArtImage } from '../cover/CoverArtImage'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; import { coverStorageKeyFromRef } from '../cover/storageKeys'; import type { CoverPrefetchPriority } from '../cover/types'; import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes'; import { resolveCoverDisplayTier } from '../cover/tiers'; import { acquireUrl } from '../utils/imageCache/urlPool'; import { OpenArtistRefInline } from './OpenArtistRefInline'; import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum'; import { useLongPressAction } from '../hooks/useLongPressAction'; import { LongPressWaveOverlay } from './LongPressWaveOverlay'; import { useDragDrop } from '../contexts/DragDropContext'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { coverServerScopeForServerId } from '../cover/serverScope'; import { appendServerQuery } from '../utils/navigation/detailServerScope'; interface AlbumCardProps { album: SubsonicAlbum; selected?: boolean; selectionMode?: boolean; onToggleSelect?: (id: string, opts?: { shiftKey?: boolean }) => void; showRating?: boolean; selectedAlbums?: SubsonicAlbum[]; disableArtwork?: boolean; /** Layout-native cover square width in CSS px (from parent grid). */ displayCssPx?: number; /** @deprecated Use displayCssPx — kept for call-site transition only */ artworkSize?: number; /** Appended to `/album/:id`, e.g. `lossless=1`. */ linkQuery?: string; /** In-page scroll viewport (`VirtualCardGrid` `scrollRootId`) for cover IO priority. */ observeScrollRootId?: string; /** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */ ensurePriority?: CoverPrefetchPriority; /** Artist/detail grids: API `coverArt` is enough — skip per-card library_resolve IPC. */ libraryResolve?: boolean; } function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [], disableArtwork = false, displayCssPx = COVER_DENSE_GRID_MIN_CELL_CSS_PX, artworkSize: _artworkSize, observeScrollRootId, ensurePriority, linkQuery, libraryResolve = false, }: AlbumCardProps) { const { t } = useTranslation(); const { isHolding, pressBind } = useLongPressAction({ onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined), onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined), }); const navigate = useNavigate(); const navigateToAlbum = useNavigateToAlbum(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const enqueue = usePlayerStore(s => s.enqueue); const activeServerId = useAuthStore(s => s.activeServerId ?? ''); const offlineServerId = album.serverId ?? activeServerId; const localEntries = useLocalPlaybackStore(s => s.entries); const isOffline = isOfflinePinComplete(album.id, offlineServerId); const albumLinkQuery = useMemo( () => appendServerQuery(linkQuery, album.serverId), [linkQuery, album.serverId], ); void localEntries; const psyDrag = useDragDrop(); const coverServerScope = useMemo( () => coverServerScopeForServerId(album.serverId), [album.serverId], ); const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve }); const dragCoverKey = useMemo(() => { if (!coverRef) return ''; const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' }); return coverStorageKeyFromRef(coverRef, tier); }, [coverRef, displayCssPx]); const isNewAlbum = isAlbumRecentlyAdded(album.created); const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]); const handleClick = (opts?: { shiftKey?: boolean }) => { if (selectionMode) { onToggleSelect?.(album.id, opts); return; } navigateToAlbum(album.id, { search: albumLinkQuery }); }; return (
handleClick({ shiftKey: e.shiftKey })} role="button" tabIndex={0} aria-label={`${album.name} von ${album.artist}`} onKeyDown={e => e.key === 'Enter' && handleClick()} onContextMenu={(e) => { e.preventDefault(); if (selectionMode && selectedAlbums.length > 0) { openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album'); } else { openContextMenu(e.clientX, e.clientY, album, 'album'); } }} onMouseDown={e => { if (selectionMode || e.button !== 0) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); const coverUrl = dragCoverKey ? acquireUrl(dragCoverKey) ?? undefined : undefined; psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl }, me.clientX, me.clientY); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{!disableArtwork && coverRef ? ( ) : (
)} {(isNewAlbum || (isOffline && !selectionMode)) && (
{isNewAlbum && (
{t('common.new', 'New')}
)} {isOffline && !selectionMode && (
)}
)} {selectionMode && (
{selected && }
)} {!selectionMode && (
)}

{album.name}

navigate(`/artist/${id}`)} as="none" linkTag="span" linkClassName="track-artist-link" />

{album.year &&

{album.year}

} {showRating && (album.userRating ?? 0) > 0 && (
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
)}
); } export default memo(AlbumCard);