- {coverId ? (
-

{
- e.currentTarget.style.display = 'none';
- e.currentTarget.parentElement?.classList.add('fallback-visible');
- }}
- />
- ) : (
-
- )}
-
+
+ {nameInitial(artist.name)}
{artist.name}
diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx
new file mode 100644
index 00000000..f7a25b6c
--- /dev/null
+++ b/src/pages/Help.tsx
@@ -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 (
+
+
+ {open &&
{a}
}
+
+ );
+}
+
+export default function Help() {
+ const { t } = useTranslation();
+ const [openKey, setOpenKey] = useState
(null);
+
+ const toggle = (key: string) => setOpenKey(prev => prev === key ? null : key);
+
+ const sections: FaqSection[] = [
+ {
+ icon: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ title: t('help.s5'),
+ items: [
+ { q: t('help.q16'), a: t('help.a16') },
+ { q: t('help.q17'), a: t('help.a17') },
+ ],
+ },
+ {
+ icon: ,
+ 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 (
+
+
{t('help.title')}
+
+
+ {sections.map((section, si) => (
+
+
+ {section.icon}
+
{section.title}
+
+
+ {section.items.map((item, ii) => {
+ const key = `${si}-${ii}`;
+ return
toggle(key)} />;
+ })}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx
new file mode 100644
index 00000000..a69a2ec4
--- /dev/null
+++ b/src/pages/RandomAlbums.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [renderKey, setRenderKey] = useState(0);
+ const [progress, setProgress] = useState(0);
+ const timerRef = useRef | null>(null);
+ const progressRef = useRef | 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 (
+
+
+
{t('randomAlbums.title')}
+
+
+
+ {/* Countdown progress bar */}
+
+
+ {loading && albums.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 479b99a2..6e3716a0 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -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() {
{t('settings.logout')}
+
+ {/* About */}
+
+
+
+
{t('settings.aboutTitle')}
+
+
+
+

+
+
+ Psysonic
+
+
+ {t('settings.aboutVersion')} 1.0.5
+
+
+
+
+
+ {t('settings.aboutDesc')}
+
+
+ {t('settings.aboutFeatures')}
+
+
+
+
+
+
+ {t('settings.aboutLicense')}
+ {t('settings.aboutLicenseText')}
+
+
+ Stack
+ {t('settings.aboutBuiltWith')}
+
+
+
+
+
+
);
}
diff --git a/src/styles/components.css b/src/styles/components.css
index 82301651..d1193b98 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -21,11 +21,33 @@
inset: 0;
background-size: cover;
background-position: center;
- transition: transform 8s ease;
+ transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
transform: scale(1.05);
}
.hero:hover .hero-bg { transform: scale(1); }
+.hero-dots {
+ position: absolute;
+ bottom: var(--space-4);
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ gap: 6px;
+ z-index: 10;
+}
+.hero-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-full);
+ background: rgba(255, 255, 255, 0.35);
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+.hero-dot:hover:not(.hero-dot-active) { background: rgba(255, 255, 255, 0.6); }
+.hero-dot-active { width: 24px; background: rgba(255, 255, 255, 0.9); }
+
.hero-overlay {
position: absolute;
inset: 0;
@@ -189,6 +211,19 @@
color: var(--text-muted);
box-shadow: var(--shadow-sm);
}
+.artist-card-avatar-initial {
+ background: var(--bg-card);
+ border: 2px solid;
+ box-shadow: none;
+ overflow: hidden;
+}
+.artist-card-avatar-initial span {
+ font-size: 2.5rem;
+ font-weight: 800;
+ font-family: var(--font-display);
+ line-height: 1;
+ user-select: none;
+}
.artist-card-name {
font-weight: 600;
font-size: 14px;
@@ -269,6 +304,20 @@
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
+
+.random-albums-progress {
+ height: 2px;
+ background: var(--border-subtle);
+ border-radius: var(--radius-full);
+ margin-bottom: 1.5rem;
+ overflow: hidden;
+}
+.random-albums-progress-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: var(--radius-full);
+ transition: width 0.1s linear;
+}
@media (min-width: 1024px) {
.album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
}
@@ -428,12 +477,12 @@
.album-detail-hero {
display: flex;
gap: var(--space-6);
- align-items: flex-end;
+ align-items: flex-start;
padding: var(--space-4) 0 var(--space-6);
}
.album-detail-cover {
- width: 180px;
- height: 180px;
+ width: clamp(120px, 15vw, 200px);
+ height: clamp(120px, 15vw, 200px);
border-radius: var(--radius-lg);
object-fit: cover;
box-shadow: var(--shadow-lg);
@@ -447,8 +496,9 @@
font-size: 48px;
color: var(--text-muted);
}
-.album-detail-title { font-family: var(--font-display); font-size: 32px; font-weight: 800; color: var(--text-primary); line-height: 1.1; margin: var(--space-2) 0 var(--space-1); }
-.album-detail-artist { font-size: 16px; color: var(--accent); font-weight: 600; }
+.album-detail-meta { min-width: 0; flex: 1; }
+.album-detail-title { font-family: var(--font-display); font-size: clamp(20px, 3vw, 32px); font-weight: 800; color: var(--text-primary); line-height: 1.1; margin: var(--space-2) 0 var(--space-1); overflow-wrap: break-word; }
+.album-detail-artist { font-size: clamp(13px, 1.5vw, 16px); color: var(--accent); font-weight: 600; }
.album-detail-artist-link {
background: none;
border: none;
@@ -464,8 +514,22 @@
color: var(--text-primary);
text-decoration: underline;
}
-.album-detail-info { display: flex; gap: var(--space-3); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
-.album-detail-actions { display: flex; gap: var(--space-3); align-items: center; }
+.album-detail-info { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
+.album-detail-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; row-gap: var(--space-2); }
+
+.download-hint {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-secondary);
+ background: rgba(203, 166, 247, 0.08);
+ border: 1px solid rgba(203, 166, 247, 0.2);
+ border-radius: var(--radius-full);
+ padding: 3px 10px 3px 8px;
+ cursor: default;
+ white-space: nowrap;
+}
.download-progress-wrap {
display: flex;
@@ -503,7 +567,7 @@
.tracklist { padding: 0 var(--space-6) var(--space-6); }
.tracklist-header {
display: grid;
- grid-template-columns: 36px minmax(120px, 3fr) minmax(100px, 2fr) 70px 80px 60px;
+ grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
font-size: 11px;
@@ -514,18 +578,53 @@
border-bottom: 1px solid var(--border-subtle);
margin-bottom: var(--space-2);
}
+.tracklist-header.tracklist-va {
+ grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
+}
.track-row {
display: grid;
- grid-template-columns: 36px minmax(120px, 3fr) minmax(100px, 2fr) 70px 80px 60px;
+ grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
gap: var(--space-3);
- align-items: center;
+ align-items: start;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
}
+.track-row.track-row-va {
+ grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
+}
+
+.tracklist-total {
+ display: grid;
+ grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
+ border-top: 1px solid var(--border-subtle);
+ padding: var(--space-3) var(--space-4);
+ margin-top: var(--space-1);
+}
+.tracklist-total.tracklist-va {
+ grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
+}
+.tracklist-total-label {
+ grid-column: 1 / -2;
+ text-align: right;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding-right: var(--space-3);
+}
+.tracklist-total-value {
+ text-align: right;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+}
.track-row:hover { background: var(--bg-hover); }
+.track-row > * { padding-top: 6px; padding-bottom: 6px; }
/* CD / Disc separator */
.disc-header {
@@ -547,7 +646,8 @@
.track-num { font-size: 13px; color: var(--text-muted); text-align: right; font-variant-numeric: tabular-nums; }
.track-info { min-width: 0; }
-.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); overflow-wrap: break-word; word-break: break-word; }
+.track-artist-cell { min-width: 0; display: flex; align-items: flex-start; }
.track-artist { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-codec {
display: block;
@@ -664,6 +764,8 @@
.artist-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); transition: background var(--transition-fast); width: 100%; text-align: left; }
.artist-row:hover { background: var(--bg-hover); }
.artist-avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent); flex-shrink: 0; }
+.artist-avatar-initial { background: var(--bg-card); border: 2px solid; }
+.artist-avatar-initial span { font-size: 1rem; font-weight: 700; font-family: var(--font-display); line-height: 1; user-select: none; }
.artist-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.artist-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
@@ -679,7 +781,20 @@
.settings-section-header { display: flex; align-items: center; gap: var(--space-2); color: var(--accent); margin-bottom: var(--space-3); }
.settings-section-header h2 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.settings-card { background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); }
+
+/* ─ Help Page ─ */
+.help-list { display: flex; flex-direction: column; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
+.help-item { border-bottom: 1px solid var(--border-subtle); }
+.help-item:last-child { border-bottom: none; }
+.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); transition: background var(--transition-fast); }
+.help-question:hover { background: var(--bg-hover); }
+.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); }
+.help-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform 0.2s ease; }
+.help-item-open .help-chevron { transform: rotate(180deg); color: var(--accent); }
+.help-answer { padding: var(--space-3) var(--space-5) var(--space-5); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-hover); border-top: 1px solid var(--border-subtle); }
.settings-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
+.settings-about { display: flex; flex-direction: column; }
+.settings-about-header { display: flex; align-items: center; gap: var(--space-4); }
/* Toggle switch */
.toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; }
@@ -841,32 +956,35 @@
transform: translateY(-1px);
}
-/* ── Main layout: cover left, upcoming right ── */
+/* ── Main layout: two columns, left = cover+controls, right = playlist ── */
.fs-layout {
position: relative;
z-index: 1;
display: flex;
flex: 1;
- align-items: flex-start;
+ flex-direction: row;
+ align-items: center;
justify-content: center;
- gap: clamp(24px, 4vw, 60px);
- padding: clamp(52px, 8vh, 90px) clamp(32px, 5vw, 80px) 12px;
+ gap: clamp(28px, 4vw, 72px);
+ padding: 72px clamp(40px, 6vw, 100px) 48px;
min-height: 0;
- overflow: visible;
+ overflow: hidden;
}
-/* Left column */
+/* Left column: cover + track info + progress + controls */
.fs-left {
display: flex;
flex-direction: column;
align-items: center;
+ gap: 20px;
flex-shrink: 0;
+ /* Width = cover size (cover fills 100% of this column) */
+ width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px));
}
-/* Cover: explicit size, same formula as .fs-right */
+/* Cover fills the column width */
.fs-cover-wrap {
- position: relative;
- width: clamp(240px, min(38vw, 50vh), 500px);
+ width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-xl);
overflow: hidden;
@@ -892,14 +1010,134 @@
color: var(--text-muted);
}
-/* Right column: same explicit width+height as cover */
+/* Track metadata */
+.fs-track-info {
+ text-align: center;
+ width: 100%;
+}
+.fs-title {
+ font-family: var(--font-display);
+ font-size: clamp(16px, 1.8vw, 26px);
+ font-weight: 800;
+ color: var(--text-primary);
+ margin: 0 0 4px;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.fs-artist {
+ font-size: clamp(13px, 1.1vw, 16px);
+ font-weight: 600;
+ color: var(--accent);
+ margin: 0;
+}
+.fs-album {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin: 3px 0 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.fs-codec {
+ display: inline-block;
+ font-size: 10px;
+ font-family: monospace;
+ letter-spacing: 0.04em;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.1);
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ color: var(--text-muted);
+ margin-top: 6px;
+}
+
+/* Progress bar */
+.fs-progress-wrap {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+}
+.fs-time {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+ min-width: 36px;
+ text-align: center;
+}
+.fs-progress-bar {
+ flex: 1;
+ position: relative;
+}
+.fs-progress-bar input[type="range"] {
+ width: 100%;
+ height: 4px;
+ background: linear-gradient(to right, var(--ctp-mauve) var(--pct), rgba(255,255,255,0.12) var(--pct));
+ border-radius: 2px;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+}
+.fs-progress-bar input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: white;
+ cursor: pointer;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.5);
+ transition: transform var(--transition-fast);
+}
+.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); }
+
+/* Transport controls */
+.fs-controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+}
+.fs-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ background: transparent;
+}
+.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
+.fs-btn.active { color: var(--accent); }
+.fs-btn-sm { width: 36px; height: 36px; }
+.fs-btn-play {
+ width: 68px;
+ height: 68px;
+ background: white;
+ color: #1e1e2e;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
+}
+.fs-btn-play:hover {
+ background: var(--ctp-lavender);
+ color: #1e1e2e;
+ transform: scale(1.06);
+ box-shadow: 0 12px 40px rgba(0,0,0,0.6);
+}
+
+/* Right column: upcoming tracks — same vertical center as left column */
.fs-right {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
- width: clamp(240px, min(38vw, 50vh), 500px);
- height: clamp(240px, min(38vw, 50vh), 500px);
+ width: clamp(240px, 26vw, 380px);
+ align-self: center;
+ height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px));
overflow: hidden;
}
.fs-upcoming-title {
@@ -932,9 +1170,7 @@
background: transparent;
flex-shrink: 0;
}
-.fs-upcoming-item:hover {
- background: rgba(255,255,255,0.07);
-}
+.fs-upcoming-item:hover { background: rgba(255,255,255,0.07); }
.fs-upcoming-art {
width: 44px;
height: 44px;
@@ -978,210 +1214,6 @@
flex-shrink: 0;
}
-/* ── Footer: track info + progress + controls (centered, full width) ── */
-.fs-footer {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- flex: 1;
- width: 100%;
- padding: 0 clamp(40px, 8vw, 120px) 48px;
-}
-
-.fs-footer-main {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-start;
- width: 100%;
- gap: 16px;
- margin-top: clamp(20px, 4vh, 80px);
- margin-bottom: auto; /* Pushes controls down to bottom */
-}
-
-/* Track metadata */
-.fs-track-info {
- text-align: center;
- width: 100%;
-}
-.fs-title {
- font-family: var(--font-display);
- font-size: clamp(18px, 2.2vw, 28px);
- font-weight: 800;
- color: var(--text-primary);
- margin: 0 0 4px;
- line-height: 1.2;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.fs-artist {
- font-size: clamp(13px, 1.2vw, 16px);
- font-weight: 600;
- color: var(--accent);
- margin: 0;
-}
-.fs-album {
- font-size: 13px;
- color: var(--text-secondary);
- margin: 3px 0 0;
-}
-.fs-codec {
- display: inline-block;
- font-size: 10px;
- font-family: monospace;
- letter-spacing: 0.04em;
- background: rgba(255,255,255,0.06);
- border: 1px solid rgba(255,255,255,0.1);
- padding: 2px 8px;
- border-radius: var(--radius-full);
- color: var(--text-muted);
- margin-top: 6px;
-}
-
-/* Progress + volume container */
-.fs-bottom {
- display: flex;
- flex-direction: column;
- gap: 6px;
- width: 100%;
- max-width: 700px;
- align-items: center;
-}
-
-/* Progress */
-.fs-progress-wrap {
- display: flex;
- align-items: center;
- gap: 12px;
- width: 100%;
-}
-.fs-time {
- font-size: 12px;
- color: var(--text-muted);
- font-variant-numeric: tabular-nums;
- min-width: 36px;
- text-align: center;
-}
-.fs-progress-bar {
- flex: 1;
- position: relative;
-}
-.fs-progress-bar input[type="range"] {
- width: 100%;
- height: 4px;
- background: linear-gradient(to right, var(--ctp-mauve) var(--pct), rgba(255,255,255,0.12) var(--pct));
- border-radius: 2px;
- cursor: pointer;
- appearance: none;
- -webkit-appearance: none;
-}
-.fs-progress-bar input[type="range"]::-webkit-slider-thumb {
- appearance: none;
- -webkit-appearance: none;
- width: 14px;
- height: 14px;
- border-radius: 50%;
- background: white;
- cursor: pointer;
- box-shadow: 0 1px 4px rgba(0,0,0,0.5);
- transition: transform var(--transition-fast);
-}
-.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); }
-
-
-/* Controls: flat centered flex row */
-.fs-controls {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 16px;
-}
-
-.fs-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 48px;
- height: 48px;
- border-radius: 50%;
- color: var(--text-secondary);
- cursor: pointer;
- transition: all var(--transition-fast);
- background: transparent;
-}
-.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
-.fs-btn.active { color: var(--accent); }
-.fs-btn-sm { width: 36px; height: 36px; }
-.fs-btn-play {
- width: 68px;
- height: 68px;
- background: white;
- color: #1e1e2e;
- box-shadow: 0 8px 32px rgba(0,0,0,0.5);
-}
-.fs-btn-play:hover {
- background: var(--ctp-lavender);
- color: #1e1e2e;
- transform: scale(1.06);
- box-shadow: 0 12px 40px rgba(0,0,0,0.6);
-}
-
-
-@keyframes fsIn {
- from { transform: translateY(100%); opacity: 0; }
- to { transform: translateY(0); opacity: 1; }
-}
-
-/* Blurred cover background */
-.fs-bg {
- position: absolute;
- inset: -10%;
- background-size: cover;
- background-position: center;
- filter: blur(40px) brightness(0.35) saturate(1.4);
- transform: scale(1.15);
- z-index: 0;
-}
-
-.fs-bg-overlay {
- position: absolute;
- inset: 0;
- background: linear-gradient(
- 160deg,
- rgba(17, 17, 27, 0.55) 0%,
- rgba(17, 17, 27, 0.85) 60%,
- rgba(17, 17, 27, 0.97) 100%
- );
- z-index: 0;
-}
-
-/* Close button */
-.fs-close {
- position: absolute;
- top: 20px;
- left: 24px;
- z-index: 10;
- width: 44px;
- height: 44px;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.08);
- backdrop-filter: blur(8px);
- color: var(--text-secondary);
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all var(--transition-fast);
- cursor: pointer;
-}
-.fs-close:hover {
- background: rgba(255, 255, 255, 0.16);
- color: var(--text-primary);
- transform: translateY(-1px);
-}
-
/* Chat */
.chat-popup {
position: absolute;
diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts
new file mode 100644
index 00000000..451ec002
--- /dev/null
+++ b/src/utils/imageCache.ts
@@ -0,0 +1,93 @@
+const DB_NAME = 'psysonic-img-cache';
+const STORE_NAME = 'images';
+const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
+
+// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session)
+const objectUrlCache = new Map
();
+
+let db: IDBDatabase | null = null;
+let dbPromise: Promise | null = null;
+
+function openDB(): Promise {
+ if (db) return Promise.resolve(db);
+ if (dbPromise) return dbPromise;
+ dbPromise = new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, 1);
+ req.onupgradeneeded = e => {
+ const database = (e.target as IDBOpenDBRequest).result;
+ if (!database.objectStoreNames.contains(STORE_NAME)) {
+ database.createObjectStore(STORE_NAME, { keyPath: 'key' });
+ }
+ };
+ req.onsuccess = e => {
+ db = (e.target as IDBOpenDBRequest).result;
+ resolve(db!);
+ };
+ req.onerror = () => reject(req.error);
+ });
+ return dbPromise;
+}
+
+async function getBlob(key: string): Promise {
+ try {
+ const database = await openDB();
+ return new Promise(resolve => {
+ const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(key);
+ req.onsuccess = () => {
+ const entry = req.result;
+ resolve(entry && Date.now() - entry.timestamp < MAX_AGE_MS ? entry.blob : null);
+ };
+ req.onerror = () => resolve(null);
+ });
+ } catch {
+ return null;
+ }
+}
+
+async function putBlob(key: string, blob: Blob): Promise {
+ try {
+ const database = await openDB();
+ await new Promise(resolve => {
+ const tx = database.transaction(STORE_NAME, 'readwrite');
+ tx.objectStore(STORE_NAME).put({ key, blob, timestamp: Date.now() });
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ });
+ } catch {
+ // Ignore write errors
+ }
+}
+
+/**
+ * Returns a cached object URL for an image.
+ * @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
+ * @param cacheKey A stable key that identifies the image across sessions.
+ */
+export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise {
+ if (!fetchUrl) return '';
+
+ // 1. In-memory hit (same session)
+ const existing = objectUrlCache.get(cacheKey);
+ if (existing) return existing;
+
+ // 2. IndexedDB hit (persisted from previous session)
+ const blob = await getBlob(cacheKey);
+ if (blob) {
+ const objUrl = URL.createObjectURL(blob);
+ objectUrlCache.set(cacheKey, objUrl);
+ return objUrl;
+ }
+
+ // 3. Network fetch → store in IDB → return object URL
+ try {
+ const resp = await fetch(fetchUrl);
+ if (!resp.ok) return fetchUrl;
+ const newBlob = await resp.blob();
+ putBlob(cacheKey, newBlob); // fire-and-forget
+ const objUrl = URL.createObjectURL(newBlob);
+ objectUrlCache.set(cacheKey, objUrl);
+ return objUrl;
+ } catch {
+ return fetchUrl; // fallback: direct URL
+ }
+}