import type { EntityRatingSupportLevel, SubsonicItemGenre, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes'; import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react'; import { CoverArtImage } from '../cover/CoverArtImage'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; import { useCoverLightboxSrc } from '../cover/lightbox'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile'; import { useAlbumDetailBack } from '../hooks/useAlbumDetailBack'; import { useThemeStore } from '../store/themeStore'; import StarRating from './StarRating'; import { copyEntityShareLink } from '../utils/share/copyEntityShareLink'; import { showToast } from '../utils/ui/toast'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; import { formatLongDuration } from '../utils/format/formatDuration'; import { formatMb } from '../utils/format/formatBytes'; import { sanitizeHtml } from '../utils/sanitizeHtml'; import { OpenArtistRefInline } from './OpenArtistRefInline'; import { tooltipAttrs } from './tooltipAttrs'; import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy'; import { deriveAlbumGenreTags } from '../utils/library/genreTags'; import { genreColor } from '../utils/library/genreColor'; /** True when the album artist label means "no single artist" — `getArtistInfo` * has nothing meaningful to return for these, so the Artist Bio entry is hidden. */ function isVariousArtistsLabel(name: string | undefined | null): boolean { if (!name) return false; const trimmed = name.trim().toLowerCase(); return ( trimmed === 'various artists' || trimmed === 'various' || trimmed === 'va' || trimmed === 'multiple artists' || trimmed === 'verschiedene interpreten' || trimmed === 'verschiedene' ); } function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) { const { t } = useTranslation(); return createPortal(
e.stopPropagation()}>

{t('albumDetail.bioModal')}

, document.body ); } /** Cursor-anchored genre list (context-menu style) for the "+N" chip. */ function GenreMenu({ genres, pos, onPick, onClose, }: { genres: string[]; pos: { x: number; y: number }; onPick: (genre: string) => void; onClose: () => void; }) { const { t } = useTranslation(); const ref = useRef(null); const [coords, setCoords] = useState(pos); // Clamp into the viewport once the menu has measured its own size, then move // focus to the first genre so keyboard users land inside the menu. useLayoutEffect(() => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const pad = 8; setCoords({ x: Math.max(pad, Math.min(pos.x, window.innerWidth - rect.width - pad)), y: Math.max(pad, Math.min(pos.y, window.innerHeight - rect.height - pad)), }); el.querySelector('[role="menuitem"]')?.focus(); }, [pos]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); return; } const items = Array.from( ref.current?.querySelectorAll('[role="menuitem"]') ?? [], ); if (items.length === 0) return; const focusAt = (i: number) => items[(i + items.length) % items.length].focus(); const at = items.indexOf(document.activeElement as HTMLElement); if (e.key === 'ArrowDown') { e.preventDefault(); focusAt(at + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); focusAt(at < 0 ? -1 : at - 1); } else if (e.key === 'Home') { e.preventDefault(); focusAt(0); } else if (e.key === 'End') { e.preventDefault(); focusAt(items.length - 1); } }; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; window.addEventListener('keydown', onKey); document.addEventListener('mousedown', onDown); return () => { window.removeEventListener('keydown', onKey); document.removeEventListener('mousedown', onDown); }; }, [onClose]); return createPortal(
{genres.map(g => ( ))}
, document.body, ); } interface AlbumInfo { id: string; name: string; artist: string; artistId: string; year?: number; genre?: string; /** OpenSubsonic atomic genres — preferred over the legacy `genre` string. */ genres?: SubsonicItemGenre[]; coverArt?: string; recordLabel?: string; created?: string; } interface AlbumHeaderProps { info: AlbumInfo; /** OpenSubsonic album credits (derived from album + songs). */ headerArtistRefs: SubsonicOpenArtistRef[]; songs: SubsonicSong[]; coverArtId?: string; resolvedCoverUrl: string | null; isStarred: boolean; downloadProgress: number | null; offlineStatus: 'none' | 'queued' | 'downloading' | 'cached'; offlineProgress: { done: number; total: number } | null; bio: string | null; bioOpen: boolean; onToggleStar: () => void; onDownload: () => void; onCacheOffline: () => void; onRemoveOffline: () => void; onPlayAll: () => void; onEnqueueAll: () => void; onShuffleAll?: () => void; onBio: () => void; onCloseBio: () => void; entityRatingValue: number; onEntityRatingChange: (rating: number) => void; /** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */ entityRatingSupport: EntityRatingSupportLevel | 'unknown'; /** Offline browse action gates (favorites, download, cache, bio, ratings). */ actionPolicy?: OfflineActionPolicy; } export default function AlbumHeader({ info, headerArtistRefs, songs, coverArtId, resolvedCoverUrl, isStarred, downloadProgress, offlineStatus, offlineProgress, bio, bioOpen, onToggleStar, onDownload, onCacheOffline, onRemoveOffline, onPlayAll, onEnqueueAll, onShuffleAll, onBio, onCloseBio, entityRatingValue, onEntityRatingChange, entityRatingSupport, actionPolicy, }: AlbumHeaderProps) { const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false); const { t } = useTranslation(); const navigate = useNavigate(); const goBack = useAlbumDetailBack(); const isMobile = useIsMobile(); const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true }); const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: `${info.name} Cover`, }); const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0); const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / '); const isNewAlbum = isAlbumRecentlyAdded(info.created); const showBioButton = !isVariousArtistsLabel(info.artist); const genreTags = deriveAlbumGenreTags(info, songs); const [genreMenuPos, setGenreMenuPos] = useState<{ x: number; y: number } | null>(null); const genreMoreRef = useRef(null); const goToGenre = (genre: string) => { setGenreMenuPos(null); navigate(`/genres/${encodeURIComponent(genre)}`, { state: { returnTo: `/album/${info.id}` } }); }; const handleShareAlbum = async () => { try { const ok = await copyEntityShareLink('album', info.id); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } catch { showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } }; return ( <> {bioOpen && bio && } {lightbox} {genreMenuPos && ( { setGenreMenuPos(null); genreMoreRef.current?.focus(); }} /> )}
{resolvedCoverUrl && enableCoverArtBackground && ( <>