import { buildCoverArtUrl } from '../api/subsonicStreamUrl'; import type { EntityRatingSupportLevel, SubsonicSong } from '../api/subsonicTypes'; import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react'; import CachedImage from './CachedImage'; import CoverLightbox from './CoverLightbox'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile'; 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'; function formatSize(bytes?: number): string { if (!bytes) return ''; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function sanitizeHtml(html: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove()); doc.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { const name = attr.name.toLowerCase(); const val = attr.value.toLowerCase().trim(); if ( name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:'))) ) { el.removeAttribute(attr.name); } }); }); return doc.body.innerHTML; } function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) { const { t } = useTranslation(); return (
e.stopPropagation()}>

{t('albumDetail.bioModal')}

); } interface AlbumInfo { id: string; name: string; artist: string; artistId: string; year?: number; genre?: string; coverArt?: string; recordLabel?: string; created?: string; } interface AlbumHeaderProps { info: AlbumInfo; songs: SubsonicSong[]; coverUrl: string; coverKey: string; resolvedCoverUrl: string | null; isStarred: boolean; downloadProgress: number | null; offlineStatus: 'none' | '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'; } export default function AlbumHeader({ info, songs, coverUrl, coverKey, resolvedCoverUrl, isStarred, downloadProgress, offlineStatus, offlineProgress, bio, bioOpen, onToggleStar, onDownload, onCacheOffline, onRemoveOffline, onPlayAll, onEnqueueAll, onShuffleAll, onBio, onCloseBio, entityRatingValue, onEntityRatingChange, entityRatingSupport, }: AlbumHeaderProps) { const { t } = useTranslation(); const navigate = useNavigate(); const isMobile = useIsMobile(); const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); const [lightboxOpen, setLightboxOpen] = useState(false); 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 lightboxCoverSrc = useMemo( () => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''), [info.coverArt], ); 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 && } {lightboxOpen && info.coverArt && ( setLightboxOpen(false)} /> )}
{resolvedCoverUrl && enableCoverArtBackground && ( <>