mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
chore: prepare release v1.0.1 and ignore CLAUDE.md
This commit is contained in:
+131
-122
@@ -9,6 +9,15 @@ 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 { 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);
|
||||
@@ -29,10 +38,28 @@ function codecLabel(song: { suffix?: string; bitRate?: number; samplingRate?: nu
|
||||
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 (
|
||||
<div className="star-rating" role="radiogroup" aria-label="Bewertung">
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1,2,3,4,5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
@@ -40,7 +67,7 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n} Stern${n !== 1 ? 'e' : ''}`}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
@@ -53,18 +80,20 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
|
||||
interface BioModalProps { bio: string; onClose: () => void; }
|
||||
function BioModal({ bio, onClose }: BioModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="Künstler-Biografie">
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Schließen"><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>Künstler-Biografie</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: bio }} data-selectable />
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('albumDetail.bioModal')}</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthStore();
|
||||
@@ -85,19 +114,15 @@ export default function AlbumDetail() {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setRelatedAlbums([]);
|
||||
getAlbum(id).then(async data => {
|
||||
setAlbum(data);
|
||||
getAlbum(id).then(async data => {
|
||||
setAlbum(data);
|
||||
setIsStarred(!!data.album.starred);
|
||||
|
||||
const initialStarred = new Set<string>();
|
||||
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||||
setStarredSongs(initialStarred);
|
||||
|
||||
setLoading(false);
|
||||
// Fetch related albums by the same artist
|
||||
setLoading(false);
|
||||
try {
|
||||
const artistData = await getArtist(data.album.artistId);
|
||||
// Filter out the current album from the related list
|
||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch related albums', e);
|
||||
@@ -127,10 +152,10 @@ export default function AlbumDetail() {
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
@@ -144,7 +169,7 @@ export default function AlbumDetail() {
|
||||
if (!album) return;
|
||||
if (bio) { setBioOpen(true); return; }
|
||||
const info = await getArtistInfo(album.album.artistId);
|
||||
setBio(info.biography ?? 'Keine Biografie verfügbar.');
|
||||
setBio(info.biography ?? t('albumDetail.noBio'));
|
||||
setBioOpen(true);
|
||||
};
|
||||
|
||||
@@ -155,24 +180,23 @@ export default function AlbumDetail() {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${albumName}.zip`);
|
||||
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
console.log(`Saved to ${path}`);
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${albumName}.zip`;
|
||||
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 fehlgeschlagen:', e);
|
||||
console.error('Download failed:', e);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
@@ -181,45 +205,34 @@ export default function AlbumDetail() {
|
||||
const toggleStar = async () => {
|
||||
if (!album) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred); // Optimistic UI update
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
if (currentlyStarred) {
|
||||
await unstar(album.album.id);
|
||||
} else {
|
||||
await star(album.album.id);
|
||||
}
|
||||
if (currentlyStarred) await unstar(album.album.id);
|
||||
else await star(album.album.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred); // Revert on failure
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // prevent play on double click trigger
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
|
||||
// Optimistic UI
|
||||
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');
|
||||
}
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
// Revert
|
||||
const revert = new Set(starredSongs);
|
||||
setStarredSongs(revert);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">Album nicht gefunden.</div>;
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const coverUrl = info.coverArt ? buildCoverArtUrl(info.coverArt, 400) : '';
|
||||
@@ -238,96 +251,95 @@ export default function AlbumDetail() {
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
|
||||
<ChevronLeft size={16} /> Zurück
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<img className="album-detail-cover" src={coverUrl} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge" style={{ marginBottom: '0.5rem' }}>Album</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={`Zu ${info.artist} wechseln`}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={`Weitere Alben von ${info.recordLabel} anzeigen`}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
|
||||
<Play size={16} fill="currentColor" /> Alle abspielen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
|
||||
{coverUrl ? (
|
||||
<img className="album-detail-cover" src={coverUrl} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge" style={{ marginBottom: '0.5rem' }}>{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
<ListPlus size={16} /> Einreihen
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)} disabled={downloading}>
|
||||
<Download size={16} /> {downloading ? t('albumDetail.downloading') : t('albumDetail.download')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
|
||||
{isStarred ? 'Favorit' : 'Als Favorit'}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> Künstler-Bio
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)} disabled={downloading}>
|
||||
<Download size={16} /> {downloading ? 'Lade…' : 'Download (ZIP)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header">
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>Titel</div>
|
||||
<div>Format</div>
|
||||
<div style={{ textAlign: 'center' }}>Favorit</div>
|
||||
<div>Bewertung</div>
|
||||
<div style={{ textAlign: 'right' }}>Dauer</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
|
||||
<div>{t('albumDetail.trackRating')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// Group songs by disc number
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
@@ -368,10 +380,7 @@ export default function AlbumDetail() {
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'song',
|
||||
track
|
||||
}));
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
|
||||
@@ -390,10 +399,10 @@ export default function AlbumDetail() {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
@@ -415,7 +424,7 @@ export default function AlbumDetail() {
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>Mehr von {info.artist}</h2>
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user