import React, { useEffect, useState, useCallback } 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 { 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 [offlineStorageFull, setOfflineStorageFull] = useState(false); const { downloadAlbum, deleteAlbum } = useOfflineStore(); const offlineTracks = useOfflineStore(s => s.tracks); const offlineAlbums = useOfflineStore(s => s.albums); const offlineJobs = useOfflineStore(s => s.jobs); const serverId = auth.activeServerId ?? ''; const offlineStatus: 'none' | 'downloading' | 'cached' = (() => { if (!album) return 'none'; const meta = offlineAlbums[`${serverId}:${album.album.id}`]; const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!offlineTracks[`${serverId}:${tid}`]); if (isDownloaded) return 'cached'; const isDownloading = offlineJobs.some(j => j.albumId === album.album.id && (j.status === 'queued' || j.status === 'downloading')); return isDownloading ? 'downloading' : 'none'; })(); const offlineProgress = (() => { if (!album) return null; const albumJobs = offlineJobs.filter(j => j.albumId === album.album.id); if (albumJobs.length === 0) return null; const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length; return { done, total: albumJobs.length }; })(); 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 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 })); 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)); } }; 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); }; // 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, false); 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={offlineStatus} offlineProgress={offlineProgress} onCacheOffline={handleCacheOffline} onRemoveOffline={handleRemoveOffline} /> {offlineStorageFull && (
{t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })}
)} 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 => )}
)}
); }