import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { invoke } from '@tauri-apps/api/core'; import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { join } from '@tauri-apps/api/path'; import { useZipDownloadStore } from '../store/zipDownloadStore'; 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'; import { showToast } from '../utils/toast'; 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 setStarredOverride = usePlayerStore(s => s.setStarredOverride); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); 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 [isStarred, setIsStarred] = useState(false); const [starredSongs, setStarredSongs] = useState>(new Set()); const [offlineStorageFull, setOfflineStorageFull] = useState(false); const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const deleteAlbum = useOfflineStore(s => s.deleteAlbum); const serverId = auth.activeServerId ?? ''; const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown'; const [albumEntityRating, setAlbumEntityRating] = useState(0); const [filterText, setFilterText] = useState(''); const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [sortClickCount, setSortClickCount] = useState(0); // Derive a stable albumId for the selectors below (empty string when not yet loaded). const albumId = album?.album.id ?? ''; // Selectors return primitives so Zustand only triggers a re-render when the VALUE // actually changes — not on every `jobs` array mutation during batch downloads. const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => { if (!albumId) return 'none'; const meta = s.albums[`${serverId}:${albumId}`]; const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); return isDownloaded ? 'cached' : 'none'; }); const isOfflineDownloading = useOfflineJobStore(s => !!albumId && s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')) ); const offlineProgressDone = useOfflineJobStore(s => { if (!albumId) return 0; return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length; }); const offlineProgressTotal = useOfflineJobStore(s => { if (!albumId) return 0; return s.jobs.filter(j => j.albumId === albumId).length; }); const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus; const offlineProgress = offlineProgressTotal > 0 ? { done: offlineProgressDone, total: offlineProgressTotal } : 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]); useEffect(() => { if (!id) return; if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0); }, [id, album?.album.id, album?.album.userRating]); const handlePlayAll = () => { if (!album) return; const albumGenre = album.album.genre; const tracks = album.songs.map(s => { const t = songToTrack(s); if (!t.genre && albumGenre) t.genre = albumGenre; return t; }); if (tracks[0]) playTrack(tracks[0], tracks); }; const handleEnqueueAll = () => { if (!album) return; const albumGenre = album.album.genre; const tracks = album.songs.map(s => { const t = songToTrack(s); if (!t.genre && albumGenre) t.genre = albumGenre; return t; }); enqueue(tracks); }; const handlePlaySong = (song: SubsonicSong) => { if (!album) return; const albumGenre = album.album.genre; const tracks = album.songs.map(s => { const t = songToTrack(s); if (!t.genre && albumGenre) t.genre = albumGenre; return t; }); const track = tracks.find(t => t.id === song.id) || songToTrack(song); playTrack(track, tracks); }; const handleRate = async (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); usePlayerStore.getState().setUserRatingOverride(songId, rating); await setRating(songId, rating); }; const handleAlbumEntityRating = async (rating: number) => { if (!album || album.album.id !== id) return; const albumId = album.album.id; const ratingAtStart = album.album.userRating ?? 0; setAlbumEntityRating(rating); if (albumEntityRatingSupport !== 'full') return; try { await setRating(albumId, rating); setAlbum(cur => cur && cur.album.id === albumId ? { ...cur, album: { ...cur.album, userRating: rating } } : cur, ); } catch (err) { setAlbumEntityRating(ratingAtStart); setEntityRatingSupport(serverId, 'track_only'); showToast( typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), 4500, 'error', ); } }; 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; const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; const filename = `${sanitizeFilename(name)}.zip`; const destPath = await join(folder, filename); const url = buildDownloadUrl(albumId); const downloadId = crypto.randomUUID(); const { start, complete, fail } = useZipDownloadStore.getState(); start(downloadId, filename); try { await invoke('download_zip', { id: downloadId, url, destPath }); complete(downloadId); } catch (e) { fail(downloadId); console.error('ZIP download failed:', e); } }; 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); setStarredOverride(song.id, !wasStarred); 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)); setStarredOverride(song.id, wasStarred); } }; const handleCacheOffline = useCallback(async () => { if (!album) return; const maxBytes = auth.maxCacheMb * 1024 * 1024; try { const usedBytes = await invoke('get_offline_cache_size'); if (usedBytes >= maxBytes) { setOfflineStorageFull(true); return; } } catch { // If we can't check, proceed anyway } setOfflineStorageFull(false); downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId); }, [album, auth.maxCacheMb, downloadAlbum, serverId]); const handleRemoveOffline = () => { if (!album) return; deleteAlbum(album.album.id, serverId); }; const handleSort = (key: typeof sortKey) => { if (key === 'natural') return; if (sortKey === key) { const nextCount = sortClickCount + 1; if (nextCount >= 3) { setSortKey('natural'); setSortDir('asc'); setSortClickCount(0); } else { setSortDir(d => d === 'asc' ? 'desc' : 'asc'); setSortClickCount(nextCount); } } else { setSortKey(key); setSortDir('asc'); setSortClickCount(1); } }; // Must be before early returns — hooks must be called unconditionally. const mergedStarredSongs = useMemo(() => new Set([ ...[...starredSongs].filter(id => starredOverrides[id] !== false), ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), ]), [starredSongs, starredOverrides]); const displayedSongs = useMemo(() => { if (!album) return []; const q = filterText.trim().toLowerCase(); if (!q && sortKey === 'natural') return album.songs; let result = [...album.songs]; if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q)); if (sortKey !== 'natural') { result.sort((a, b) => { let av: string | number; let bv: string | number; switch (sortKey) { case 'title': av = a.title; bv = b.title; break; case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break; case 'album': av = a.album ?? ''; bv = b.album ?? ''; break; case 'favorite': av = mergedStarredSongs.has(a.id) ? 1 : 0; bv = mergedStarredSongs.has(b.id) ? 1 : 0; break; case 'rating': av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0; bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0; break; case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break; default: av = a.title; bv = b.title; } if (typeof av === 'number' && typeof bv === 'number') { return sortDir === 'asc' ? av - bv : bv - av; } return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string); }); } return result; }, [album, filterText, sortKey, sortDir, mergedStarredSongs, ratings, userRatingOverrides]); // Hooks must be called unconditionally — derive from nullable album state. // useMemo is required: buildCoverArtUrl generates a new salt on every call, so without // memoization every re-render (e.g. currentTrack change) produces a new fetchUrl, // which cancels and restarts the useCachedUrl effect → background never resolves. const coverUrl = useMemo(() => album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '', [album?.album.coverArt]); const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]); 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)} offlineStatus={resolvedOfflineStatus} offlineProgress={offlineProgress} onCacheOffline={handleCacheOffline} onRemoveOffline={handleRemoveOffline} entityRatingValue={albumEntityRating} onEntityRatingChange={handleAlbumEntityRating} entityRatingSupport={albumEntityRatingSupport} /> {offlineStorageFull && (
{t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })}
)} {songs.length > 0 && (
setFilterText(e.target.value)} /> {filterText && ( )}
)} {relatedAlbums.length > 0 && (

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

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