import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; 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 { useDownloadModalStore } from '../store/downloadModalStore'; import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; import AlbumCard from '../components/AlbumCard'; import AlbumHeader from '../components/AlbumHeader'; import AlbumTrackList from '../components/AlbumTrackList'; import { 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'; } export default function AlbumDetail() { const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const auth = useAuthStore(); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); const openContextMenu = usePlayerStore(s => s.openContextMenu); const starredOverrides = usePlayerStore(s => s.starredOverrides); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); 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 () => { if (!album) return; const { name, id: albumId } = album.album; // Ask for folder before starting download if not already set const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; 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); const buffer = await blob.arrayBuffer(); const path = await join(folder, `${sanitizeFilename(name)}.zip`); await writeFile(path, new Uint8Array(buffer)); } catch (e) { console.error('Download failed:', e); setDownloadProgress(null); } finally { setTimeout(() => setDownloadProgress(null), 60000); } }; const toggleStar = async () => { if (!album) return; const wasStarred = isStarred; setIsStarred(!wasStarred); try { if (wasStarred) await unstar(album.album.id); else await star(album.album.id); } catch (e) { console.error('Failed to toggle star', e); setIsStarred(wasStarred); } }; const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => { e.stopPropagation(); const wasStarred = starredSongs.has(song.id); const next = new Set(starredSongs); if (wasStarred) next.delete(song.id); else next.add(song.id); setStarredSongs(next); try { if (wasStarred) 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 hasVariousArtists = songs.some(s => s.artist !== info.artist); return (
setBioOpen(false)} /> starredOverrides[id] !== false), ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), ])} onPlaySong={handlePlaySong} onRate={handleRate} onToggleSongStar={toggleSongStar} onContextMenu={openContextMenu} /> {relatedAlbums.length > 0 && (

{t('albumDetail.moreByArtist', { artist: info.artist })}

{relatedAlbums.map(a => )}
)}
); }