import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes'; import React, { useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { Play, Heart, ExternalLink, 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'; /** 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 ); } interface AlbumInfo { id: string; name: string; artist: string; artistId: string; year?: number; genre?: string; 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 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}
{resolvedCoverUrl && enableCoverArtBackground && ( <>