feat: IndexedDB image caching, initial-avatars, and discovery improvements (v1.0.5)

This commit is contained in:
Psychotoxical
2026-03-12 22:28:25 +01:00
parent 7e0cffc892
commit f8c45efd2b
24 changed files with 1076 additions and 397 deletions
+33 -12
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus } from 'lucide-react';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
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';
@@ -9,6 +9,7 @@ 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 {
@@ -253,22 +254,28 @@ export default function AlbumDetail() {
}
};
// 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 <div className="loading-center"><div className="spinner" /></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) : '';
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 (
<div className="album-detail animate-fade-in">
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
<div className="album-detail-header">
{coverUrl && (
{resolvedCoverUrl && (
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${coverUrl})` }}
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
aria-hidden="true"
/>
)}
@@ -280,7 +287,7 @@ export default function AlbumDetail() {
</button>
<div className="album-detail-hero">
{coverUrl ? (
<img className="album-detail-cover" src={coverUrl} alt={`${info.name} Cover`} />
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
) : (
<div className="album-detail-cover album-cover-placeholder"></div>
)}
@@ -352,9 +359,13 @@ export default function AlbumDetail() {
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)}>
<Download size={16} /> {t('albumDetail.download')}
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
</div>
</div>
</div>
@@ -362,9 +373,10 @@ export default function AlbumDetail() {
</div>
<div className="tracklist">
<div className="tracklist-header">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div style={{ textAlign: 'center' }}>#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div>{t('albumDetail.trackFormat')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
<div>{t('albumDetail.trackRating')}</div>
@@ -392,7 +404,7 @@ export default function AlbumDetail() {
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className="track-row"
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
onDoubleClick={() => handlePlaySong(song)}
onContextMenu={(e) => {
e.preventDefault();
@@ -418,10 +430,12 @@ export default function AlbumDetail() {
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
{song.artist !== info.artist && (
<span className="track-artist">{song.artist}</span>
)}
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
)}
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
{(song.suffix || song.bitRate) && (
<span className="track-codec" style={{ marginTop: 0 }}>
@@ -452,10 +466,17 @@ export default function AlbumDetail() {
</div>
));
})()}
{/* Total row */}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
{relatedAlbums.length > 0 && (
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
<div style={{ borderTop: '1px solid var(--border-subtle)', marginBottom: '2rem' }} />
<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} />)}
+1 -3
View File
@@ -3,7 +3,7 @@ import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist' | 'newest' | 'random';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
export default function Albums() {
const { t } = useTranslation();
@@ -55,8 +55,6 @@ export default function Albums() {
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
{ value: 'newest', label: t('albums.sortNewest') },
{ value: 'random', label: t('albums.sortRandom') },
];
return (
+8 -5
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, star, unstar } from '../api/subsonic';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
@@ -158,11 +159,12 @@ export default function ArtistDetail() {
<div className="artist-detail-header">
<div className="artist-detail-avatar">
{coverId ? (
<img
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { e.currentTarget.style.display = 'none'; }}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<Users size={64} color="var(--text-muted)" />
@@ -265,11 +267,12 @@ export default function ArtistDetail() {
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{song.coverArt && (
<img
<CachedImage
src={buildCoverArtUrl(song.coverArt, 64)}
cacheKey={coverArtCacheKey(song.coverArt, 64)}
alt={song.album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
+31 -34
View File
@@ -1,13 +1,36 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
import { Users, LayoutGrid, List } from 'lucide-react';
import { getArtists, SubsonicArtist } from '../api/subsonic';
import { LayoutGrid, List } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
// Catppuccin accent colors — one is picked deterministically from the artist name
const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
'var(--ctp-blue)', 'var(--ctp-lavender)',
];
function nameColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return CTP_COLORS[h % CTP_COLORS.length];
}
function nameInitial(name: string): string {
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
if (letter) return letter.toUpperCase();
// Fallback: first alphanumeric (e.g. "1349")
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
return alnum?.toUpperCase() ?? '?';
}
export default function Artists() {
const { t } = useTranslation();
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
@@ -124,7 +147,7 @@ export default function Artists() {
{!loading && viewMode === 'grid' && (
<div className="album-grid-wrap">
{visible.map(artist => {
const coverId = artist.coverArt || artist.id;
const color = nameColor(artist.name);
return (
<div
key={artist.id}
@@ -135,21 +158,8 @@ export default function Artists() {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
>
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
{coverId ? (
<img
src={buildCoverArtUrl(coverId, 200)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={32} />
)}
<Users size={32} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div>
<div className="artist-card-name">{artist.name}</div>
@@ -170,7 +180,7 @@ export default function Artists() {
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => {
const coverId = artist.coverArt || artist.id;
const color = nameColor(artist.name);
return (
<button
key={artist.id}
@@ -182,21 +192,8 @@ export default function Artists() {
}}
id={`artist-${artist.id}`}
>
<div className="artist-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
{coverId ? (
<img
src={buildCoverArtUrl(coverId, 100)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={18} />
)}
<Users size={18} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
+108
View File
@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface FaqItem { q: string; a: string; }
interface FaqSection { icon: React.ReactNode; title: string; items: FaqItem[]; }
function AccordionItem({ q, a, open, onToggle }: FaqItem & { open: boolean; onToggle: () => void }) {
return (
<div className={`help-item${open ? ' help-item-open' : ''}`}>
<button className="help-question" onClick={onToggle} aria-expanded={open}>
<span>{q}</span>
<ChevronDown size={16} className="help-chevron" />
</button>
{open && <div className="help-answer">{a}</div>}
</div>
);
}
export default function Help() {
const { t } = useTranslation();
const [openKey, setOpenKey] = useState<string | null>(null);
const toggle = (key: string) => setOpenKey(prev => prev === key ? null : key);
const sections: FaqSection[] = [
{
icon: <Rocket size={18} />,
title: t('help.s1'),
items: [
{ q: t('help.q1'), a: t('help.a1') },
{ q: t('help.q2'), a: t('help.a2') },
{ q: t('help.q3'), a: t('help.a3') },
],
},
{
icon: <Play size={18} />,
title: t('help.s2'),
items: [
{ q: t('help.q4'), a: t('help.a4') },
{ q: t('help.q5'), a: t('help.a5') },
{ q: t('help.q6'), a: t('help.a6') },
{ q: t('help.q7'), a: t('help.a7') },
{ q: t('help.q8'), a: t('help.a8') },
],
},
{
icon: <LibraryBig size={18} />,
title: t('help.s3'),
items: [
{ q: t('help.q9'), a: t('help.a9') },
{ q: t('help.q10'), a: t('help.a10') },
{ q: t('help.q11'), a: t('help.a11') },
],
},
{
icon: <Settings2 size={18} />,
title: t('help.s4'),
items: [
{ q: t('help.q12'), a: t('help.a12') },
{ q: t('help.q13'), a: t('help.a13') },
{ q: t('help.q14'), a: t('help.a14') },
{ q: t('help.q15'), a: t('help.a15') },
],
},
{
icon: <Radio size={18} />,
title: t('help.s5'),
items: [
{ q: t('help.q16'), a: t('help.a16') },
{ q: t('help.q17'), a: t('help.a17') },
],
},
{
icon: <Wrench size={18} />,
title: t('help.s6'),
items: [
{ q: t('help.q18'), a: t('help.a18') },
{ q: t('help.q19'), a: t('help.a19') },
{ q: t('help.q20'), a: t('help.a20') },
{ q: t('help.q21'), a: t('help.a21') },
],
},
];
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{sections.map((section, si) => (
<section key={si} className="settings-section">
<div className="settings-section-header">
{section.icon}
<h2>{section.title}</h2>
</div>
<div className="help-list">
{section.items.map((item, ii) => {
const key = `${si}-${ii}`;
return <AccordionItem key={key} q={item.q} a={item.a} open={openKey === key} onToggle={() => toggle(key)} />;
})}
</div>
</section>
))}
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw } from 'lucide-react';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import { useTranslation } from 'react-i18next';
const INTERVAL_MS = 30000;
const ALBUM_COUNT = 30;
export default function RandomAlbums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [renderKey, setRenderKey] = useState(0);
const [progress, setProgress] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await getAlbumList('random', ALBUM_COUNT);
setAlbums(data);
setRenderKey(k => k + 1);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
const startCycle = useCallback(() => {
// Clear existing timers
if (timerRef.current) clearInterval(timerRef.current);
if (progressRef.current) clearInterval(progressRef.current);
// Reset progress bar
setProgress(0);
const startTime = Date.now();
progressRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
setProgress(Math.min((elapsed / INTERVAL_MS) * 100, 100));
}, 100);
// Auto-refresh
timerRef.current = setInterval(() => {
load().then(() => startCycle());
}, INTERVAL_MS);
}, [load]);
useEffect(() => {
load().then(() => startCycle());
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (progressRef.current) clearInterval(progressRef.current);
};
}, [load, startCycle]);
const handleManualRefresh = () => {
load().then(() => startCycle());
};
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
<button
className="btn btn-ghost"
onClick={handleManualRefresh}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
</div>
{/* Countdown progress bar */}
<div className="random-albums-progress">
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
</div>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : (
<div className="album-grid-wrap animate-fade-in" key={renderKey}>
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
)}
</div>
);
}
+52 -1
View File
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink
} from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { useAuthStore, ServerProfile } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { pingWithCredentials } from '../api/subsonic';
@@ -352,6 +353,56 @@ export default function Settings() {
<LogOut size={16} /> {t('settings.logout')}
</button>
</section>
{/* About */}
<section className="settings-section">
<div className="settings-section-header">
<Info size={18} />
<h2>{t('settings.aboutTitle')}</h2>
</div>
<div className="settings-card settings-about">
<div className="settings-about-header">
<img src="/logo.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.5
</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
{t('settings.aboutDesc')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
{t('settings.aboutFeatures')}
</p>
<div className="divider" style={{ margin: '1rem 0' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
</div>
</div>
<button
className="btn btn-ghost"
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
>
<ExternalLink size={14} />
{t('settings.aboutRepo')}
</button>
</div>
</section>
</div>
);
}