import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react'; import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useNavigate } from 'react-router-dom'; import { open } from '@tauri-apps/plugin-shell'; import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; import AlbumCard from '../components/AlbumCard'; import CachedImage, { useCachedUrl } from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; function sanitizeFilename(name: string): string { return name .replace(/[/\\?%*:|"<>]/g, '-') .replace(/\.{2,}/g, '.') .replace(/^[\s.]+|[\s.]+$/g, '') .substring(0, 200) || 'download'; } function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; } function formatSize(bytes?: number): string { if (!bytes) return ''; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function codecLabel(song: { suffix?: string; bitRate?: number }): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); if (song.bitRate) parts.push(`${song.bitRate} kbps`); return parts.join(' · '); } /** Strip dangerous tags/attributes from server-provided HTML (e.g. artist bios from Last.fm) */ 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 StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) { const { t } = useTranslation(); const [hover, setHover] = useState(0); return (
{[1,2,3,4,5].map(n => ( ))}
); } interface BioModalProps { bio: string; onClose: () => void; } function BioModal({ bio, onClose }: BioModalProps) { const { t } = useTranslation(); return (
e.stopPropagation()}>

{t('albumDetail.bioModal')}

); } export default function AlbumDetail() { const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const auth = useAuthStore(); const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); const openContextMenu = usePlayerStore(s => s.openContextMenu); const currentTrack = usePlayerStore(s => s.currentTrack); const [album, setAlbum] = useState> | null>(null); const [relatedAlbums, setRelatedAlbums] = useState([]); const [ratings, setRatings] = useState>({}); const [bio, setBio] = useState(null); const [bioOpen, setBioOpen] = useState(false); const [loading, setLoading] = useState(true); const [downloadProgress, setDownloadProgress] = useState(null); const [isStarred, setIsStarred] = useState(false); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSongId, setHoveredSongId] = useState(null); useEffect(() => { if (!id) return; setLoading(true); setRelatedAlbums([]); getAlbum(id).then(async data => { setAlbum(data); setIsStarred(!!data.album.starred); const initialStarred = new Set(); data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); }); setStarredSongs(initialStarred); setLoading(false); try { const artistData = await getArtist(data.album.artistId); setRelatedAlbums(artistData.albums.filter(a => a.id !== id)); } catch (e) { console.error('Failed to fetch related albums', e); } }).catch(() => setLoading(false)); }, [id]); const handlePlayAll = () => { if (!album) return; const tracks = album.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, })); if (tracks[0]) playTrack(tracks[0], tracks); }; const handleEnqueueAll = () => { if (!album) return; const tracks = album.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, })); enqueue(tracks); }; const handlePlaySong = (song: SubsonicSong) => { const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }; playTrack(track, [track]); }; const handleRate = async (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); await setRating(songId, rating); }; const handleBio = async () => { if (!album) return; if (bio) { setBioOpen(true); return; } const info = await getArtistInfo(album.album.artistId); setBio(info.biography ?? t('albumDetail.noBio')); setBioOpen(true); }; const handleDownload = async (albumName: string, albumId: string) => { setDownloadProgress(0); try { const url = buildDownloadUrl(albumId); const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const contentLength = response.headers.get('Content-Length'); const total = contentLength ? parseInt(contentLength, 10) : 0; const chunks: Uint8Array[] = []; if (total && response.body) { const reader = response.body.getReader(); let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.length; setDownloadProgress(Math.round((received / total) * 100)); } } else { const buffer = await response.arrayBuffer() as ArrayBuffer; chunks.push(new Uint8Array(buffer)); setDownloadProgress(100); } const blob = new Blob(chunks); if (auth.downloadFolder) { const buffer = await blob.arrayBuffer(); const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`); await writeFile(path, new Uint8Array(buffer)); } else { const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = `${sanitizeFilename(albumName)}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(blobUrl), 2000); } } catch (e) { console.error('Download failed:', e); setDownloadProgress(null); } finally { // keep bar visible at 100% for 3 seconds so user sees completion setTimeout(() => setDownloadProgress(null), 60000); } }; const toggleStar = async () => { if (!album) return; const currentlyStarred = isStarred; setIsStarred(!currentlyStarred); try { if (currentlyStarred) await unstar(album.album.id); else await star(album.album.id); } catch (e) { console.error('Failed to toggle star', e); setIsStarred(currentlyStarred); } }; const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => { e.stopPropagation(); const currentlyStarred = starredSongs.has(song.id); const nextStarred = new Set(starredSongs); if (currentlyStarred) nextStarred.delete(song.id); else nextStarred.add(song.id); setStarredSongs(nextStarred); try { if (currentlyStarred) await unstar(song.id, 'song'); else await star(song.id, 'song'); } catch (err) { console.error('Failed to toggle song star', err); setStarredSongs(new Set(starredSongs)); } }; // Hooks must be called unconditionally — derive from nullable album state const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : ''; const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : ''; const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); if (loading) return
; if (!album) return
{t('albumDetail.notFound')}
; const { album: info, songs } = album; const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const hasVariousArtists = songs.some(s => s.artist !== info.artist); const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0); return (
{bioOpen && bio && setBioOpen(false)} />}
{resolvedCoverUrl && (