mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: v1.18.0 — Offline Mode (Beta), MPRIS Seek, 2 New Themes, Perf Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+52
-3
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
@@ -34,8 +34,11 @@ import TooltipPortal from './components/TooltipPortal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import LastfmIndicator from './components/LastfmIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import OfflineBanner from './components/OfflineBanner';
|
||||
import OfflineLibrary from './pages/OfflineLibrary';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
@@ -59,6 +62,26 @@ function AppShell() {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
// Auto-navigate to offline library when no connection but cached content exists
|
||||
const prevConnStatus = useRef(connStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevConnStatus.current;
|
||||
prevConnStatus.current = connStatus;
|
||||
|
||||
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
|
||||
navigate('/offline', { replace: true });
|
||||
}
|
||||
// Return from offline page only when reconnecting (not when user navigates there manually while online)
|
||||
if (connStatus === 'connected' && prev === 'disconnected' && location.pathname === '/offline') {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [connStatus, hasOfflineContent, location.pathname, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
@@ -155,8 +178,11 @@ function AppShell() {
|
||||
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
|
||||
</button>
|
||||
</header>
|
||||
{connStatus === 'disconnected' && hasOfflineContent && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
|
||||
)}
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
{connStatus === 'disconnected' && (
|
||||
{connStatus === 'disconnected' && !hasOfflineContent && (
|
||||
<OfflineOverlay
|
||||
serverName={serverName}
|
||||
onRetry={connRetry}
|
||||
@@ -180,6 +206,7 @@ function AppShell() {
|
||||
<Route path="/now-playing" element={<NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -277,6 +304,28 @@ function TauriEventBridge() {
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
|
||||
{
|
||||
const u = await listen<number>('media:seek-relative', e => {
|
||||
const s = usePlayerStore.getState();
|
||||
const dur = s.currentTrack?.duration;
|
||||
if (!dur) return;
|
||||
s.seek(Math.max(0, s.currentTime + e.payload) / dur);
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
{
|
||||
const u = await listen<number>('media:seek-absolute', e => {
|
||||
const s = usePlayerStore.getState();
|
||||
const dur = s.currentTrack?.duration;
|
||||
if (!dur) return;
|
||||
s.seek(e.payload / dur);
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// Handle close → minimize to tray if enabled (Tauri 2 approach)
|
||||
const win = getCurrentWindow();
|
||||
const u = await win.onCloseRequested(async (event) => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import { Play, HardDriveDownload } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
|
||||
@@ -10,9 +12,15 @@ interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
}
|
||||
|
||||
export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
function AlbumCard({ album }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isOffline = useOfflineStore(s => {
|
||||
const meta = s.albums[`${serverId}:${album.id}`];
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
});
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
|
||||
return (
|
||||
@@ -48,6 +56,11 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{isOffline && (
|
||||
<div className="album-card-offline-badge" aria-label="Offline available">
|
||||
<HardDriveDownload size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
@@ -66,3 +79,5 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AlbumCard);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
@@ -70,10 +70,14 @@ interface AlbumHeaderProps {
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
offlineStatus: 'none' | 'downloading' | 'cached';
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
onToggleStar: () => void;
|
||||
onDownload: () => void;
|
||||
onCacheOffline: () => void;
|
||||
onRemoveOffline: () => void;
|
||||
onPlayAll: () => void;
|
||||
onEnqueueAll: () => void;
|
||||
onBio: () => void;
|
||||
@@ -88,10 +92,14 @@ export default function AlbumHeader({
|
||||
resolvedCoverUrl,
|
||||
isStarred,
|
||||
downloadProgress,
|
||||
offlineStatus,
|
||||
offlineProgress,
|
||||
bio,
|
||||
bioOpen,
|
||||
onToggleStar,
|
||||
onDownload,
|
||||
onCacheOffline,
|
||||
onRemoveOffline,
|
||||
onPlayAll,
|
||||
onEnqueueAll,
|
||||
onBio,
|
||||
@@ -216,10 +224,30 @@ export default function AlbumHeader({
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
<span className="download-hint">
|
||||
<Info size={12} />
|
||||
{t('albumDetail.downloadHintShort')}
|
||||
</span>
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,11 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
|
||||
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
|
||||
|
||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||
// transition (which would cause the background to flash empty before fading in).
|
||||
const stableBgUrl = useRef('');
|
||||
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
|
||||
|
||||
// Resolve cover thumbnail via cache
|
||||
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
@@ -111,7 +116,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<HeroBg url={resolvedBgUrl} />
|
||||
<HeroBg url={stableBgUrl.current} />
|
||||
<div className="hero-overlay" aria-hidden="true" />
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
|
||||
@@ -8,14 +8,25 @@ interface Props {
|
||||
currentTrack: Track | null;
|
||||
}
|
||||
|
||||
interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
plainLyrics: string | null;
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
// Session-level cache — survives tab switches (component unmount/remount).
|
||||
// Cleared implicitly when the app restarts.
|
||||
const lyricsCache = new Map<string, CachedLyrics>();
|
||||
|
||||
export default function LyricsPane({ currentTrack }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [fetchedFor, setFetchedFor] = useState<string | null>(null);
|
||||
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
|
||||
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
|
||||
|
||||
const hasSynced = syncedLines !== null && syncedLines.length > 0;
|
||||
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
|
||||
@@ -24,7 +35,20 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
const prevActive = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack || currentTrack.id === fetchedFor) return;
|
||||
if (!currentTrack) return;
|
||||
|
||||
// Serve from cache if available
|
||||
const hit = lyricsCache.get(currentTrack.id);
|
||||
if (hit) {
|
||||
setSyncedLines(hit.syncedLines);
|
||||
setPlainLyrics(hit.plainLyrics);
|
||||
setNotFound(hit.notFound);
|
||||
setLoading(false);
|
||||
lineRefs.current = [];
|
||||
prevActive.current = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSyncedLines(null);
|
||||
setPlainLyrics(null);
|
||||
@@ -41,27 +65,26 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
).then(result => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
setFetchedFor(currentTrack.id);
|
||||
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
|
||||
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
if (result.syncedLyrics) {
|
||||
const lines = parseLrc(result.syncedLyrics);
|
||||
setSyncedLines(lines.length > 0 ? lines : null);
|
||||
}
|
||||
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
|
||||
const synced = lines && lines.length > 0 ? lines : null;
|
||||
lyricsCache.set(currentTrack.id, { syncedLines: synced, plainLyrics: result.plainLyrics, notFound: false });
|
||||
setSyncedLines(synced);
|
||||
setPlainLyrics(result.plainLyrics);
|
||||
}).catch(() => {
|
||||
if (!cancelled) { setLoading(false); setNotFound(true); }
|
||||
if (!cancelled) {
|
||||
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
|
||||
setLoading(false);
|
||||
setNotFound(true);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset when track changes
|
||||
useEffect(() => {
|
||||
setFetchedFor(null);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
const activeIdx = hasSynced
|
||||
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
|
||||
: -1;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { WifiOff, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
onRetry: () => void;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export default function OfflineBanner({ onRetry, isChecking }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="offline-banner">
|
||||
<WifiOff size={14} />
|
||||
<span>{t('connection.offlineModeBanner')}</span>
|
||||
<button
|
||||
className="offline-banner-retry"
|
||||
onClick={onRetry}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw size={12} className={isChecking ? 'spin' : ''} />
|
||||
{t('connection.retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -69,6 +71,11 @@ export default function Sidebar({
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const offlineJobs = useOfflineStore(s => s.jobs);
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -143,6 +150,18 @@ export default function Sidebar({
|
||||
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
|
||||
</NavLink>
|
||||
|
||||
{hasOfflineContent && (
|
||||
<NavLink
|
||||
to="/offline"
|
||||
className={({ isActive }) => `nav-link nav-link-offline ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.offlineLibrary') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<HardDriveDownload size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.offlineLibrary')}</span>}
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
|
||||
<NavLink
|
||||
@@ -172,6 +191,19 @@ export default function Sidebar({
|
||||
<Settings size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
|
||||
</NavLink>
|
||||
|
||||
{activeJobs.length > 0 && (
|
||||
<div
|
||||
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobs.length }) : undefined}
|
||||
data-tooltip-pos="right"
|
||||
>
|
||||
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||
{!isCollapsed && (
|
||||
<span>{t('sidebar.downloadingTracks', { n: activeJobs.length })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,8 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
|
||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
||||
{ id: 't-800', label: 'T-800', bg: '#1f242d', card: '#0a0c10', accent: '#00d4ff' },
|
||||
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
|
||||
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+80
-24
@@ -22,7 +22,9 @@ const enTranslation = {
|
||||
collapse: 'Collapse Sidebar',
|
||||
updateAvailable: 'Update available',
|
||||
updateReady: '{{version}} is ready',
|
||||
updateLink: 'Go to release →'
|
||||
updateLink: 'Go to release →',
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
offlineLibrary: 'Offline Library',
|
||||
},
|
||||
home: {
|
||||
starred: 'Personal Favorites',
|
||||
@@ -94,8 +96,10 @@ const enTranslation = {
|
||||
artistBio: 'Artist Bio',
|
||||
download: 'Download (ZIP)',
|
||||
downloading: 'Loading…',
|
||||
downloadHint: 'FLAC/WAV albums are zipped server-side first — large albums may take a moment before the download starts.',
|
||||
downloadHintShort: 'Server zips first — may take a moment depending on file size',
|
||||
cacheOffline: 'Make available offline',
|
||||
offlineCached: 'Available offline',
|
||||
offlineDownloading: 'Caching… ({{n}}/{{total}})',
|
||||
removeOffline: 'Remove offline cache',
|
||||
favoriteAdd: 'Add to Favorites',
|
||||
favoriteRemove: 'Remove from Favorites',
|
||||
favorite: 'Favorite',
|
||||
@@ -246,6 +250,11 @@ const enTranslation = {
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'No server connection',
|
||||
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
|
||||
offlineModeBanner: 'Offline Mode — playing from local cache',
|
||||
offlineLibraryTitle: 'Offline Library',
|
||||
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
retry: 'Retry',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
@@ -325,8 +334,13 @@ const enTranslation = {
|
||||
behavior: 'App Behavior',
|
||||
trayTitle: 'Minimize to Tray',
|
||||
trayDesc: 'Minimize app to the system tray on close (X)',
|
||||
cacheTitle: 'Max. Cache Size',
|
||||
cacheDesc: 'For preloaded tracks',
|
||||
cacheTitle: 'Max. Image Cache Size',
|
||||
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
|
||||
cacheUsed: 'Used: {{images}} images · {{offline}} offline tracks',
|
||||
cacheClearBtn: 'Clear Cache',
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
downloadsTitle: 'Download Folder',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
@@ -409,7 +423,7 @@ const enTranslation = {
|
||||
a8: 'Click the repeat button in the player bar to cycle through: Off → Repeat All → Repeat One.',
|
||||
s3: 'Library',
|
||||
q9: 'How do I download an album?',
|
||||
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server zips the album first — this may take a moment for large albums or lossless files (FLAC / WAV). A progress bar shows the download status.',
|
||||
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server compresses the album into a ZIP file first — for large albums or lossless files (FLAC / WAV) this can take a moment before the download actually starts. This is normal: the progress bar appears once the server finishes zipping and the transfer begins.',
|
||||
q10: 'How do I star / favorite tracks and albums?',
|
||||
a10: 'Click the star icon on any track row or on the album header. Starred items appear in the Favorites section in the sidebar.',
|
||||
q11: 'What is the hero carousel on the home page?',
|
||||
@@ -569,7 +583,9 @@ const deTranslation = {
|
||||
collapse: 'Sidebar ausblenden',
|
||||
updateAvailable: 'Update verfügbar',
|
||||
updateReady: '{{version}} ist bereit',
|
||||
updateLink: 'Zum Release →'
|
||||
updateLink: 'Zum Release →',
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
},
|
||||
home: {
|
||||
starred: 'Persönliche Favoriten',
|
||||
@@ -641,8 +657,10 @@ const deTranslation = {
|
||||
artistBio: 'Künstler-Bio',
|
||||
download: 'Download (ZIP)',
|
||||
downloading: 'Lade…',
|
||||
downloadHint: 'FLAC/WAV-Alben werden serverseitig zuerst gezippt — bei großen Alben kann es einen Moment dauern, bevor der Download startet.',
|
||||
downloadHintShort: 'Server zippt zuerst — je nach Dateigröße kann es etwas dauern bevor der Download startet',
|
||||
cacheOffline: 'Offline verfügbar machen',
|
||||
offlineCached: 'Offline verfügbar',
|
||||
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
|
||||
removeOffline: 'Offline-Cache löschen',
|
||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||
favoriteRemove: 'Aus Favoriten entfernen',
|
||||
favorite: 'Als Favorit',
|
||||
@@ -793,6 +811,11 @@ const deTranslation = {
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'Keine Serververbindung',
|
||||
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
|
||||
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
|
||||
offlineLibraryTitle: 'Offline-Bibliothek',
|
||||
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||
offlineAlbumCount: '{{n}} Album',
|
||||
offlineAlbumCount_plural: '{{n}} Alben',
|
||||
retry: 'Erneut versuchen',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
@@ -872,8 +895,13 @@ const deTranslation = {
|
||||
behavior: 'App-Verhalten',
|
||||
trayTitle: 'In Tray minimieren',
|
||||
trayDesc: 'App beim Schließen (X) in den System-Tray minimieren',
|
||||
cacheTitle: 'Max. Cache-Größe',
|
||||
cacheDesc: 'Für vorgeladene Tracks',
|
||||
cacheTitle: 'Max. Bild-Cache-Größe',
|
||||
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
|
||||
cacheUsed: 'Belegt: {{images}} Bilder · {{offline}} Offline-Tracks',
|
||||
cacheClearBtn: 'Cache leeren',
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
downloadsTitle: 'Download-Ordner',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
@@ -956,7 +984,7 @@ const deTranslation = {
|
||||
a8: 'Klick auf den Wiederhol-Button in der Playerleiste, um zwischen den Modi zu wechseln: Aus → Alles wiederholen → Einen wiederholen.',
|
||||
s3: 'Bibliothek',
|
||||
q9: 'Wie lade ich ein Album herunter?',
|
||||
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server zieht das Album zuerst zusammen — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann das einen Moment dauern. Ein Fortschrittsbalken zeigt den Status.',
|
||||
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server packt das Album zunächst in eine ZIP-Datei — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann es einen Moment dauern, bevor der Download tatsächlich startet. Das ist normal: Der Fortschrittsbalken erscheint erst, wenn der Server fertig ist und die Übertragung beginnt.',
|
||||
q10: 'Wie markiere ich Tracks und Alben als Favoriten?',
|
||||
a10: 'Das Stern-Icon auf einem Track oder im Album-Header anklicken. Markierte Einträge erscheinen im Bereich "Favoriten" in der Seitenleiste.',
|
||||
q11: 'Was ist das Karussell auf der Startseite?',
|
||||
@@ -1116,7 +1144,9 @@ const frTranslation = {
|
||||
collapse: 'Réduire la barre latérale',
|
||||
updateAvailable: 'Mise à jour disponible',
|
||||
updateReady: '{{version}} est prêt',
|
||||
updateLink: 'Voir la version →'
|
||||
updateLink: 'Voir la version →',
|
||||
downloadingTracks: '{{n}} pistes en cache…',
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
},
|
||||
home: {
|
||||
starred: 'Favoris personnels',
|
||||
@@ -1188,8 +1218,10 @@ const frTranslation = {
|
||||
artistBio: 'Biographie',
|
||||
download: 'Télécharger (ZIP)',
|
||||
downloading: 'Chargement…',
|
||||
downloadHint: 'Les albums FLAC/WAV sont zippés côté serveur — les grands albums peuvent prendre un moment avant le démarrage du téléchargement.',
|
||||
downloadHintShort: 'Le serveur zippe d\'abord — peut prendre un moment selon la taille',
|
||||
cacheOffline: 'Rendre disponible hors ligne',
|
||||
offlineCached: 'Disponible hors ligne',
|
||||
offlineDownloading: 'Mise en cache… ({{n}}/{{total}})',
|
||||
removeOffline: 'Supprimer le cache hors ligne',
|
||||
favoriteAdd: 'Ajouter aux favoris',
|
||||
favoriteRemove: 'Retirer des favoris',
|
||||
favorite: 'Favori',
|
||||
@@ -1340,6 +1372,11 @@ const frTranslation = {
|
||||
extern: 'Externe',
|
||||
offlineTitle: 'Pas de connexion au serveur',
|
||||
offlineSubtitle: 'Impossible d\'atteindre {{server}}. Vérifiez votre réseau ou le serveur.',
|
||||
offlineModeBanner: 'Mode hors ligne — lecture depuis le cache local',
|
||||
offlineLibraryTitle: 'Bibliothèque hors ligne',
|
||||
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
retry: 'Réessayer',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
@@ -1419,8 +1456,13 @@ const frTranslation = {
|
||||
behavior: 'Comportement de l\'application',
|
||||
trayTitle: 'Réduire dans la barre système',
|
||||
trayDesc: 'Réduire l\'application dans la barre système à la fermeture (X)',
|
||||
cacheTitle: 'Taille max. du cache',
|
||||
cacheDesc: 'Pour les pistes préchargées',
|
||||
cacheTitle: 'Taille max. du cache images',
|
||||
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
|
||||
cacheUsed: 'Utilisé : {{images}} images · {{offline}} pistes hors ligne',
|
||||
cacheClearBtn: 'Vider le cache',
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
downloadsTitle: 'Dossier de téléchargement',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
pickFolder: 'Sélectionner',
|
||||
@@ -1503,7 +1545,7 @@ const frTranslation = {
|
||||
a8: 'Cliquez sur le bouton répéter pour alterner : Désactivé → Répéter tout → Répéter un.',
|
||||
s3: 'Bibliothèque',
|
||||
q9: 'Comment télécharger un album ?',
|
||||
a9: 'Ouvrez la page de détail d\'un album et cliquez sur « Télécharger (ZIP) ». Le serveur zippe d\'abord l\'album — cela peut prendre un moment pour les grands albums ou les fichiers sans perte.',
|
||||
a9: 'Ouvrez la page de détail d\'un album et cliquez sur « Télécharger (ZIP) ». Le serveur compresse d\'abord l\'album en ZIP — pour les grands albums ou les fichiers sans perte (FLAC / WAV), cela peut prendre un moment avant que le téléchargement ne démarre réellement. La barre de progression n\'apparaît qu\'une fois la compression terminée.',
|
||||
q10: 'Comment mettre des pistes et albums en favoris ?',
|
||||
a10: 'Cliquez sur l\'icône étoile sur une piste ou dans l\'en-tête de l\'album. Les éléments favoris apparaissent dans la section Favoris de la barre latérale.',
|
||||
q11: 'Qu\'est-ce que le carousel hero sur la page d\'accueil ?',
|
||||
@@ -1663,7 +1705,9 @@ const nlTranslation = {
|
||||
collapse: 'Zijbalk inklappen',
|
||||
updateAvailable: 'Update beschikbaar',
|
||||
updateReady: '{{version}} is klaar',
|
||||
updateLink: 'Naar release →'
|
||||
updateLink: 'Naar release →',
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
},
|
||||
home: {
|
||||
starred: 'Persoonlijke favorieten',
|
||||
@@ -1735,8 +1779,10 @@ const nlTranslation = {
|
||||
artistBio: 'Artiest biografie',
|
||||
download: 'Downloaden (ZIP)',
|
||||
downloading: 'Laden…',
|
||||
downloadHint: 'FLAC/WAV-albums worden eerst ingepakt door de server — grote albums kunnen even duren voordat de download begint.',
|
||||
downloadHintShort: 'Server pakt eerst in — kan even duren afhankelijk van bestandsgrootte',
|
||||
cacheOffline: 'Offline beschikbaar maken',
|
||||
offlineCached: 'Offline beschikbaar',
|
||||
offlineDownloading: 'Wordt gecached… ({{n}}/{{total}})',
|
||||
removeOffline: 'Offline cache verwijderen',
|
||||
favoriteAdd: 'Aan favorieten toevoegen',
|
||||
favoriteRemove: 'Uit favorieten verwijderen',
|
||||
favorite: 'Favoriet',
|
||||
@@ -1887,6 +1933,11 @@ const nlTranslation = {
|
||||
extern: 'Extern',
|
||||
offlineTitle: 'Geen serververbinding',
|
||||
offlineSubtitle: 'Kan {{server}} niet bereiken. Controleer je netwerk of server.',
|
||||
offlineModeBanner: 'Offline modus — afspelen vanuit lokale cache',
|
||||
offlineLibraryTitle: 'Offline bibliotheek',
|
||||
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
retry: 'Opnieuw proberen',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
@@ -1966,8 +2017,13 @@ const nlTranslation = {
|
||||
behavior: 'App-gedrag',
|
||||
trayTitle: 'Minimaliseren naar systeemvak',
|
||||
trayDesc: 'App naar het systeemvak minimaliseren bij sluiten (X)',
|
||||
cacheTitle: 'Max. cachegrootte',
|
||||
cacheDesc: 'Voor vooraf geladen nummers',
|
||||
cacheTitle: 'Max. afbeeldingscachegrootte',
|
||||
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
|
||||
cacheUsed: 'Gebruikt: {{images}} afbeeldingen · {{offline}} offline nummers',
|
||||
cacheClearBtn: 'Cache wissen',
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
downloadsTitle: 'Downloadmap',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
pickFolder: 'Selecteren',
|
||||
@@ -2050,7 +2106,7 @@ const nlTranslation = {
|
||||
a8: 'Klik op de herhalknop om te wisselen: Uit → Alles herhalen → Één herhalen.',
|
||||
s3: 'Bibliotheek',
|
||||
q9: 'Hoe download ik een album?',
|
||||
a9: 'Open de detailpagina van een album en klik op "Downloaden (ZIP)". De server pakt het album eerst in — dit kan even duren voor grote albums of verliesvrije bestanden.',
|
||||
a9: 'Open de detailpagina van een album en klik op "Downloaden (ZIP)". De server comprimeert het album eerst naar een ZIP-bestand — voor grote albums of verliesvrije bestanden (FLAC / WAV) kan het even duren voordat de download daadwerkelijk start. De voortgangsbalk verschijnt pas als de server klaar is met inpakken.',
|
||||
q10: 'Hoe markeer ik nummers en albums als favoriet?',
|
||||
a10: 'Klik op het sterpictogram op een nummervak of de albumkoptekst. Gemarkeerde items verschijnen in de sectie Favorieten in de zijbalk.',
|
||||
q11: 'Wat is de hero-carrousel op de startpagina?',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverA
|
||||
import { usePlayerStore } 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';
|
||||
@@ -44,6 +45,29 @@ export default function AlbumDetail() {
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||
|
||||
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);
|
||||
@@ -183,6 +207,16 @@ export default function AlbumDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCacheOffline = () => {
|
||||
if (!album) return;
|
||||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, 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) : '';
|
||||
@@ -212,6 +246,10 @@ export default function AlbumDetail() {
|
||||
onEnqueueAll={handleEnqueueAll}
|
||||
onBio={handleBio}
|
||||
onCloseBio={() => setBioOpen(false)}
|
||||
offlineStatus={offlineStatus}
|
||||
offlineProgress={offlineProgress}
|
||||
onCacheOffline={handleCacheOffline}
|
||||
onRemoveOffline={handleRemoveOffline}
|
||||
/>
|
||||
|
||||
<AlbumTrackList
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
|
||||
export default function OfflineLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineTracks = useOfflineStore(s => s.tracks);
|
||||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id, title: t.title, artist: t.artist, album: t.album,
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (albumId: string) => {
|
||||
const tracks = buildTracks(albumId);
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
};
|
||||
|
||||
const handleEnqueue = (albumId: string) => {
|
||||
enqueue(buildTracks(albumId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="offline-library animate-fade-in">
|
||||
<div className="offline-library-header">
|
||||
<HardDriveDownload size={24} />
|
||||
<div>
|
||||
<h1 className="offline-library-title">{t('connection.offlineLibraryTitle')}</h1>
|
||||
<p className="offline-library-count">
|
||||
{t('connection.offlineAlbumCount', { n: albums.length, count: albums.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(album => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
return (
|
||||
<div key={album.id} className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
title="Zur Warteschlange hinzufügen"
|
||||
>
|
||||
+ Queue
|
||||
</button>
|
||||
<span className="offline-library-tracks">{trackCount} tracks</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+72
-6
@@ -6,7 +6,10 @@ import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
@@ -83,12 +86,20 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const fontStore = useFontStore();
|
||||
const kb = useKeybindingsStore();
|
||||
const gs = useGlobalShortcutsStore();
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const clearAllOffline = useOfflineStore(s => s.clearAll);
|
||||
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
|
||||
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -103,12 +114,36 @@ export default function Settings() {
|
||||
const [lfmPendingToken, setLfmPendingToken] = useState<string | null>(null);
|
||||
const [lfmError, setLfmError] = useState<string | null>(null);
|
||||
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
|
||||
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
||||
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
||||
lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {});
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'library') return;
|
||||
getImageCacheSize().then(setImageCacheBytes);
|
||||
invoke<number>('get_offline_cache_size').then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
}, [activeTab]);
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
setClearing(true);
|
||||
await clearImageCache();
|
||||
await clearAllOffline(serverId);
|
||||
const [imgBytes, offBytes] = await Promise.all([
|
||||
getImageCacheSize(),
|
||||
invoke<number>('get_offline_cache_size').catch(() => 0),
|
||||
]);
|
||||
setImageCacheBytes(imgBytes);
|
||||
setOfflineCacheBytes(offBytes);
|
||||
setShowClearConfirm(false);
|
||||
setClearing(false);
|
||||
}, [clearAllOffline, serverId]);
|
||||
|
||||
const startLastfmConnect = useCallback(async () => {
|
||||
setLfmError(null);
|
||||
let token: string;
|
||||
@@ -363,15 +398,24 @@ export default function Settings() {
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
|
||||
</div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>{t('settings.cacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||
{t('settings.cacheDesc')}
|
||||
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
||||
<span style={{ marginLeft: 6, color: 'var(--text-secondary)' }}>
|
||||
— {t('settings.cacheUsed', {
|
||||
images: imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…',
|
||||
offline: offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{auth.maxCacheMb} MB</span>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
|
||||
@@ -379,6 +423,28 @@ export default function Settings() {
|
||||
id="cache-size-slider"
|
||||
/>
|
||||
</div>
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
onClick={handleClearCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
{t('settings.cacheClearConfirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
||||
{t('settings.cacheClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
|
||||
export interface OfflineTrackMeta {
|
||||
id: string;
|
||||
serverId: string;
|
||||
localPath: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
artistId?: string;
|
||||
suffix: string;
|
||||
duration: number;
|
||||
bitRate?: number;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
replayGainTrackDb?: number;
|
||||
replayGainAlbumDb?: number;
|
||||
replayGainPeak?: number;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
export interface OfflineAlbumMeta {
|
||||
id: string;
|
||||
serverId: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
trackIds: string[];
|
||||
}
|
||||
|
||||
export interface DownloadJob {
|
||||
trackId: string;
|
||||
albumId: string;
|
||||
albumName: string;
|
||||
trackTitle: string;
|
||||
trackIndex: number;
|
||||
totalTracks: number;
|
||||
status: 'queued' | 'downloading' | 'done' | 'error';
|
||||
}
|
||||
|
||||
interface OfflineState {
|
||||
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
|
||||
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
|
||||
jobs: DownloadJob[];
|
||||
|
||||
isDownloaded: (trackId: string, serverId: string) => boolean;
|
||||
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
|
||||
isAlbumDownloading: (albumId: string) => boolean;
|
||||
getLocalUrl: (trackId: string, serverId: string) => string | null;
|
||||
downloadAlbum: (
|
||||
albumId: string,
|
||||
albumName: string,
|
||||
albumArtist: string,
|
||||
coverArt: string | undefined,
|
||||
year: number | undefined,
|
||||
songs: SubsonicSong[],
|
||||
serverId: string,
|
||||
) => Promise<void>;
|
||||
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
|
||||
clearAll: (serverId: string) => Promise<void>;
|
||||
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
|
||||
}
|
||||
|
||||
export const useOfflineStore = create<OfflineState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tracks: {},
|
||||
albums: {},
|
||||
jobs: [],
|
||||
|
||||
isDownloaded: (trackId, serverId) =>
|
||||
!!get().tracks[`${serverId}:${trackId}`],
|
||||
|
||||
isAlbumDownloaded: (albumId, serverId) => {
|
||||
const album = get().albums[`${serverId}:${albumId}`];
|
||||
if (!album || album.trackIds.length === 0) return false;
|
||||
return album.trackIds.every(tid => !!get().tracks[`${serverId}:${tid}`]);
|
||||
},
|
||||
|
||||
isAlbumDownloading: (albumId) =>
|
||||
get().jobs.some(
|
||||
j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')
|
||||
),
|
||||
|
||||
getLocalUrl: (trackId, serverId) => {
|
||||
const meta = get().tracks[`${serverId}:${trackId}`];
|
||||
if (!meta) return null;
|
||||
return `psysonic-local://${meta.localPath}`;
|
||||
},
|
||||
|
||||
clearAll: async (serverId) => {
|
||||
const albumKeys = Object.keys(get().albums).filter(k => k.startsWith(`${serverId}:`));
|
||||
for (const key of albumKeys) {
|
||||
const albumId = key.slice(`${serverId}:`.length);
|
||||
await get().deleteAlbum(albumId, serverId);
|
||||
}
|
||||
},
|
||||
|
||||
getAlbumProgress: (albumId) => {
|
||||
const albumJobs = get().jobs.filter(j => j.albumId === albumId);
|
||||
if (albumJobs.length === 0) return null;
|
||||
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
|
||||
return { done, total: albumJobs.length };
|
||||
},
|
||||
|
||||
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
|
||||
const CONCURRENCY = 2;
|
||||
const trackIds = songs.map(s => s.id);
|
||||
|
||||
// Register album shell + queue jobs
|
||||
set(state => ({
|
||||
albums: {
|
||||
...state.albums,
|
||||
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
|
||||
},
|
||||
jobs: [
|
||||
...state.jobs.filter(j => j.albumId !== albumId),
|
||||
...songs.map((s, i) => ({
|
||||
trackId: s.id,
|
||||
albumId,
|
||||
albumName,
|
||||
trackTitle: s.title,
|
||||
trackIndex: i,
|
||||
totalTracks: songs.length,
|
||||
status: 'queued' as const,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
|
||||
// Download in batches of CONCURRENCY
|
||||
for (let i = 0; i < songs.length; i += CONCURRENCY) {
|
||||
const batch = songs.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async song => {
|
||||
set(state => ({
|
||||
jobs: state.jobs.map(j =>
|
||||
j.trackId === song.id && j.albumId === albumId
|
||||
? { ...j, status: 'downloading' }
|
||||
: j,
|
||||
),
|
||||
}));
|
||||
|
||||
const suffix = song.suffix || 'mp3';
|
||||
const url = buildStreamUrl(song.id);
|
||||
|
||||
try {
|
||||
const localPath = await invoke<string>('download_track_offline', {
|
||||
trackId: song.id,
|
||||
serverId,
|
||||
url,
|
||||
suffix,
|
||||
});
|
||||
|
||||
set(state => ({
|
||||
tracks: {
|
||||
...state.tracks,
|
||||
[`${serverId}:${song.id}`]: {
|
||||
id: song.id,
|
||||
serverId,
|
||||
localPath,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
suffix,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
coverArt: song.coverArt,
|
||||
year: song.year,
|
||||
genre: song.genre,
|
||||
replayGainTrackDb: song.replayGain?.trackGain,
|
||||
replayGainAlbumDb: song.replayGain?.albumGain,
|
||||
replayGainPeak: song.replayGain?.trackPeak,
|
||||
cachedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
jobs: state.jobs.map(j =>
|
||||
j.trackId === song.id && j.albumId === albumId
|
||||
? { ...j, status: 'done' }
|
||||
: j,
|
||||
),
|
||||
}));
|
||||
} catch {
|
||||
set(state => ({
|
||||
jobs: state.jobs.map(j =>
|
||||
j.trackId === song.id && j.albumId === albumId
|
||||
? { ...j, status: 'error' }
|
||||
: j,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Clear completed jobs after a short delay
|
||||
setTimeout(() => {
|
||||
set(state => ({
|
||||
jobs: state.jobs.filter(
|
||||
j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'),
|
||||
),
|
||||
}));
|
||||
}, 2500);
|
||||
},
|
||||
|
||||
deleteAlbum: async (albumId, serverId) => {
|
||||
const album = get().albums[`${serverId}:${albumId}`];
|
||||
if (!album) return;
|
||||
|
||||
await Promise.all(
|
||||
album.trackIds.map(async trackId => {
|
||||
const meta = get().tracks[`${serverId}:${trackId}`];
|
||||
if (!meta) return;
|
||||
await invoke('delete_offline_track', {
|
||||
trackId,
|
||||
serverId,
|
||||
suffix: meta.suffix,
|
||||
}).catch(() => {});
|
||||
}),
|
||||
);
|
||||
|
||||
set(state => {
|
||||
const tracks = { ...state.tracks };
|
||||
album.trackIds.forEach(tid => delete tracks[`${serverId}:${tid}`]);
|
||||
const albums = { ...state.albums };
|
||||
delete albums[`${serverId}:${albumId}`];
|
||||
return { tracks, albums };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'psysonic-offline',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: state => ({ tracks: state.tracks, albums: state.albums }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event';
|
||||
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
@@ -186,7 +187,8 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
|
||||
gaplessPreloadingId = nextTrack.id;
|
||||
const nextUrl = buildStreamUrl(nextTrack.id);
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const nextUrl = useOfflineStore.getState().getLocalUrl(nextTrack.id, serverId) ?? buildStreamUrl(nextTrack.id);
|
||||
if (gaplessEnabled) {
|
||||
// Gapless ON: decode + chain directly into the Sink now, 30 s in
|
||||
// advance. By the time the track boundary arrives, the next source is
|
||||
@@ -340,6 +342,7 @@ export function initAudioListeners(): () => void {
|
||||
// Rust souvlaki MediaControls so the OS media overlay stays accurate.
|
||||
let prevTrackId: string | null = null;
|
||||
let prevIsPlaying: boolean | null = null;
|
||||
let lastMprisPositionUpdate = 0;
|
||||
|
||||
const unsubMpris = usePlayerStore.subscribe((state) => {
|
||||
const { currentTrack, isPlaying, currentTime } = state;
|
||||
@@ -359,13 +362,26 @@ export function initAudioListeners(): () => void {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Update playback state when it changes
|
||||
if (isPlaying !== prevIsPlaying) {
|
||||
// Update playback state on play/pause change
|
||||
const playbackChanged = isPlaying !== prevIsPlaying;
|
||||
if (playbackChanged) {
|
||||
prevIsPlaying = isPlaying;
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
invoke('mpris_set_playback', {
|
||||
playing: isPlaying,
|
||||
positionSecs: currentTime > 0 ? currentTime : null,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep position in sync while playing — update every ~500 ms so Plasma
|
||||
// always shows the correct time without interpolation gaps.
|
||||
if (isPlaying && Date.now() - lastMprisPositionUpdate >= 500) {
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
invoke('mpris_set_playback', {
|
||||
playing: true,
|
||||
positionSecs: currentTime,
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -502,8 +518,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
});
|
||||
|
||||
const url = buildStreamUrl(track.id);
|
||||
const authState = useAuthStore.getState();
|
||||
const url = useOfflineStore.getState().getLocalUrl(track.id, authState.activeServerId ?? '') ?? buildStreamUrl(track.id);
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
@@ -566,8 +582,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id);
|
||||
invoke('audio_play', {
|
||||
url: buildStreamUrl(currentTrack.id),
|
||||
url: coldUrl,
|
||||
volume: vol,
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
|
||||
@@ -399,6 +399,21 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.album-card-offline-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: color-mix(in srgb, var(--accent) 85%, transparent);
|
||||
color: var(--ctp-crust);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.album-card-play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -591,6 +606,82 @@
|
||||
}
|
||||
|
||||
/* ─ Album Detail ─ */
|
||||
/* ─── Offline Library ─── */
|
||||
.offline-library {
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.offline-library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.offline-library-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.offline-library-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.offline-library-card .album-card-info {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.offline-library-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.offline-library-enqueue {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.offline-library-enqueue:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.offline-library-tracks {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.offline-library-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.offline-library-delete:hover {
|
||||
color: var(--color-error, #e05050);
|
||||
}
|
||||
|
||||
.album-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -853,6 +944,28 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─ Offline cache button ─ */
|
||||
.offline-cache-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.offline-cache-btn--cached {
|
||||
color: var(--color-star-active, var(--accent));
|
||||
border-color: var(--color-star-active, var(--accent));
|
||||
}
|
||||
|
||||
.offline-cache-btn--progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
/* ─ Download folder modal ─ */
|
||||
.download-folder-pick-row {
|
||||
display: flex;
|
||||
|
||||
@@ -301,6 +301,35 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ─── Sidebar offline download queue ─── */
|
||||
.sidebar-offline-queue {
|
||||
margin: 4px var(--space-1) 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-offline-queue--collapsed {
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spin-slow {
|
||||
animation: spin-slow 2s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Main Content ─── */
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
@@ -343,6 +372,47 @@
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
/* ─── Offline Banner ─── */
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-sidebar));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offline-banner span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.offline-banner-retry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.offline-banner-retry:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.offline-banner-retry:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ─── Player Bar ─── */
|
||||
.player-bar {
|
||||
grid-area: player;
|
||||
|
||||
+413
-3
@@ -5303,12 +5303,12 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
|
||||
/* ── Active queue / now-playing: the bearer of the Ring ── */
|
||||
[data-theme='middle-earth'] .queue-item.active {
|
||||
background: rgba(212, 168, 32, 0.10);
|
||||
background: rgba(212, 168, 32, 0.14);
|
||||
box-shadow: inset 2px 0 0 #d4a820;
|
||||
}
|
||||
[data-theme='middle-earth'] .queue-item.active .queue-item-title { color: #2a1c0e; }
|
||||
[data-theme='middle-earth'] .queue-item.active .queue-item-title { color: #f8e060; }
|
||||
[data-theme='middle-earth'] .queue-item.active .queue-item-artist,
|
||||
[data-theme='middle-earth'] .queue-item.active .queue-item-duration { color: #8a6030; }
|
||||
[data-theme='middle-earth'] .queue-item.active .queue-item-duration { color: #c8a060; }
|
||||
[data-theme='middle-earth'] .np-queue-item-active {
|
||||
color: #2a1c0e;
|
||||
text-shadow: 0 0 6px rgba(212,168,32,0.40);
|
||||
@@ -5320,6 +5320,11 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
text-shadow: 0 0 6px rgba(212,168,32,0.38);
|
||||
}
|
||||
|
||||
/* ── Queue tech bar: dark text on light parchment ── */
|
||||
[data-theme='middle-earth'] .queue-current-tech {
|
||||
color: #3e2808;
|
||||
}
|
||||
|
||||
/* ── Queue + Lyrics on dark sidebar ── */
|
||||
[data-theme='middle-earth'] .queue-current-info h3 { color: #f0d880; }
|
||||
[data-theme='middle-earth'] .queue-current-sub { color: #d4a820; }
|
||||
@@ -8969,3 +8974,408 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Connection indicators */
|
||||
[data-theme='w11'] .connection-type,
|
||||
[data-theme='w11'] .connection-server { color: #797979; }
|
||||
|
||||
/* ── Barb & Ken (Games) ─────────────────────────────────────── */
|
||||
[data-theme='barb-and-ken'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23FF1B8D%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Barbieland palette */
|
||||
--ctp-crust: #07000b;
|
||||
--ctp-mantle: #110009;
|
||||
--ctp-base: #1a000f;
|
||||
--ctp-surface0: #2e0019;
|
||||
--ctp-surface1: #450026;
|
||||
--ctp-surface2: #5c0032;
|
||||
--ctp-overlay0: #8c1455;
|
||||
--ctp-overlay1: #b82070;
|
||||
--ctp-overlay2: #d93d8a;
|
||||
--ctp-text: #ffe6f3;
|
||||
--ctp-subtext1: #ffb8da;
|
||||
--ctp-subtext0: #ff80be;
|
||||
|
||||
/* Barbie Pink spectrum */
|
||||
--ctp-mauve: #FF1B8D;
|
||||
--ctp-pink: #FF69B4;
|
||||
--ctp-flamingo: #FF1493;
|
||||
--ctp-rosewater:#FFD6EC;
|
||||
--ctp-lavender: #FFB3D9;
|
||||
--ctp-maroon: #c4005e;
|
||||
--ctp-red: #FF4466;
|
||||
|
||||
/* Ken — powder blue as accent-2 */
|
||||
--ctp-blue: #89CFF0;
|
||||
--ctp-sapphire: #5BC8F5;
|
||||
--ctp-sky: #AAE8FF;
|
||||
--ctp-teal: #70d8f2;
|
||||
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-yellow: #FFE4A8;
|
||||
--ctp-peach: #FFB8D0;
|
||||
|
||||
--bg-app: #1a000f;
|
||||
--bg-sidebar: #110009;
|
||||
--bg-card: #2e0019;
|
||||
--bg-hover: rgba(255, 27, 141, 0.12);
|
||||
--bg-player: #07000b;
|
||||
--bg-glass: rgba(26, 0, 15, 0.88);
|
||||
|
||||
--accent: #FF1B8D;
|
||||
--accent-dim: rgba(255, 27, 141, 0.15);
|
||||
--accent-glow: rgba(255, 27, 141, 0.45);
|
||||
--volume-accent: #89CFF0;
|
||||
|
||||
--text-primary: #ffe6f3;
|
||||
--text-secondary:#ffb8da;
|
||||
--text-muted: #7a3055;
|
||||
--border: rgba(255, 27, 141, 0.28);
|
||||
--border-subtle: rgba(255, 27, 141, 0.12);
|
||||
--border-dropdown: rgba(255, 27, 141, 0.3);
|
||||
--shadow-dropdown: rgba(7, 0, 11, 0.9);
|
||||
|
||||
--positive: #a6e3a1;
|
||||
--warning: #FFE4A8;
|
||||
--danger: #FF4466;
|
||||
|
||||
/* Bubbly, soft rounding — very Barbie */
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
}
|
||||
|
||||
/* Polka-dot sidebar — Barbie's dreamhouse wallpaper */
|
||||
[data-theme='barb-and-ken'] .sidebar {
|
||||
background:
|
||||
radial-gradient(circle, rgba(255, 27, 141, 0.18) 2px, transparent 2px),
|
||||
radial-gradient(circle, rgba(137, 207, 240, 0.10) 2px, transparent 2px),
|
||||
linear-gradient(180deg, #110009 0%, #1a000f 100%);
|
||||
background-size: 22px 22px, 22px 22px, 100% 100%;
|
||||
background-position: 0 0, 11px 11px, 0 0;
|
||||
border-right: 1px solid rgba(255, 27, 141, 0.35);
|
||||
}
|
||||
|
||||
/* Player bar — deep magenta gradient, hot pink top border */
|
||||
[data-theme='barb-and-ken'] .player-bar {
|
||||
background: linear-gradient(180deg, #1a000f 0%, #07000b 100%);
|
||||
border-top: 2px solid #FF1B8D;
|
||||
box-shadow: 0 -6px 24px rgba(255, 27, 141, 0.18);
|
||||
}
|
||||
|
||||
/* Track name — glitter shimmer */
|
||||
@keyframes barbie-shimmer {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
[data-theme='barb-and-ken'] .player-track-name {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#FF1B8D 0%,
|
||||
#FF69B4 25%,
|
||||
#ffffff 50%,
|
||||
#FF69B4 75%,
|
||||
#FF1B8D 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: barbie-shimmer 4s linear infinite;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Artist name — Ken blue */
|
||||
[data-theme='barb-and-ken'] .player-artist-name {
|
||||
color: #89CFF0;
|
||||
}
|
||||
|
||||
/* Active nav — pink left border glow */
|
||||
[data-theme='barb-and-ken'] .nav-link.active {
|
||||
background: linear-gradient(90deg, rgba(255, 27, 141, 0.22) 0%, transparent 100%);
|
||||
color: #FF69B4 !important;
|
||||
border-left: 3px solid #FF1B8D;
|
||||
text-shadow: 0 0 8px rgba(255, 27, 141, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='barb-and-ken'] .nav-link:hover:not(.active) {
|
||||
background: rgba(255, 27, 141, 0.08);
|
||||
}
|
||||
|
||||
/* Content header — subtle pink rule */
|
||||
[data-theme='barb-and-ken'] .content-header {
|
||||
border-bottom: 1px solid rgba(255, 27, 141, 0.2);
|
||||
background: rgba(26, 0, 15, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Primary buttons — hot pink gradient */
|
||||
[data-theme='barb-and-ken'] .btn-primary,
|
||||
[data-theme='barb-and-ken'] .player-btn-primary,
|
||||
[data-theme='barb-and-ken'] .hero-play-btn {
|
||||
background: linear-gradient(135deg, #FF1B8D 0%, #FF69B4 100%);
|
||||
box-shadow: 0 4px 16px rgba(255, 27, 141, 0.45);
|
||||
color: #fff !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-theme='barb-and-ken'] .btn-primary:hover,
|
||||
[data-theme='barb-and-ken'] .player-btn-primary:hover,
|
||||
[data-theme='barb-and-ken'] .hero-play-btn:hover {
|
||||
background: linear-gradient(135deg, #e5007a 0%, #FF1B8D 100%);
|
||||
box-shadow: 0 6px 22px rgba(255, 27, 141, 0.60);
|
||||
}
|
||||
|
||||
/* Cards — pink border glow on hover */
|
||||
[data-theme='barb-and-ken'] .album-card,
|
||||
[data-theme='barb-and-ken'] .artist-card,
|
||||
[data-theme='barb-and-ken'] .card,
|
||||
[data-theme='barb-and-ken'] .settings-card {
|
||||
border: 1px solid rgba(255, 27, 141, 0.18);
|
||||
}
|
||||
|
||||
[data-theme='barb-and-ken'] .album-card:hover,
|
||||
[data-theme='barb-and-ken'] .artist-card:hover {
|
||||
border-color: rgba(255, 27, 141, 0.50);
|
||||
box-shadow: 0 4px 20px rgba(255, 27, 141, 0.20);
|
||||
}
|
||||
|
||||
/* Queue active item */
|
||||
[data-theme='barb-and-ken'] .queue-item.active {
|
||||
background: rgba(255, 27, 141, 0.08);
|
||||
border-left: 3px solid #FF1B8D;
|
||||
}
|
||||
|
||||
[data-theme='barb-and-ken'] .queue-item.active .queue-item-title {
|
||||
color: #FF69B4;
|
||||
}
|
||||
|
||||
[data-theme='barb-and-ken'] .queue-item.active .queue-item-artist,
|
||||
[data-theme='barb-and-ken'] .queue-item.active .queue-item-duration {
|
||||
color: #89CFF0;
|
||||
}
|
||||
|
||||
/* Track rows */
|
||||
[data-theme='barb-and-ken'] .track-row:hover,
|
||||
[data-theme='barb-and-ken'] .queue-item:hover {
|
||||
background: rgba(255, 27, 141, 0.07);
|
||||
}
|
||||
|
||||
/* Album detail header cover button — Ken blue glow on hover */
|
||||
[data-theme='barb-and-ken'] .album-detail-cover-btn:hover {
|
||||
box-shadow: 0 0 24px rgba(137, 207, 240, 0.35);
|
||||
}
|
||||
|
||||
/* Star (favorite) icon — gold kept, but override to hot pink */
|
||||
[data-theme='barb-and-ken'] .color-star-active {
|
||||
--color-star-active: #FF69B4;
|
||||
}
|
||||
|
||||
/* Scrollbar — thin hot pink */
|
||||
[data-theme='barb-and-ken'] ::-webkit-scrollbar { width: 5px; }
|
||||
[data-theme='barb-and-ken'] ::-webkit-scrollbar-track { background: #07000b; }
|
||||
[data-theme='barb-and-ken'] ::-webkit-scrollbar-thumb {
|
||||
background: #FF1B8D;
|
||||
border-radius: 3px;
|
||||
}
|
||||
[data-theme='barb-and-ken'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #FF69B4;
|
||||
}
|
||||
|
||||
/* Connection indicators */
|
||||
[data-theme='barb-and-ken'] .connection-type,
|
||||
[data-theme='barb-and-ken'] .connection-server { color: #7a3055; }
|
||||
|
||||
/* ── Toy Tale (Movies) ──────────────────────────────────────── */
|
||||
[data-theme='toy-tale'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23FFD600%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* Warm toy-box palette */
|
||||
--ctp-crust: #080603;
|
||||
--ctp-mantle: #100d08;
|
||||
--ctp-base: #1a1208;
|
||||
--ctp-surface0: #2a1c10;
|
||||
--ctp-surface1: #3a2818;
|
||||
--ctp-surface2: #4a3422;
|
||||
--ctp-overlay0: #7a5a35;
|
||||
--ctp-overlay1: #9a7848;
|
||||
--ctp-overlay2: #b89060;
|
||||
--ctp-text: #f0e8d8;
|
||||
--ctp-subtext1: #d8c8a8;
|
||||
--ctp-subtext0: #c0a880;
|
||||
|
||||
/* Woody gold */
|
||||
--ctp-mauve: #FFD600;
|
||||
--ctp-yellow: #FFD600;
|
||||
--ctp-peach: #FFA000;
|
||||
--ctp-rosewater:#FFE0A0;
|
||||
|
||||
/* Buzz Lightyear purple + green */
|
||||
--ctp-lavender: #9B72D6;
|
||||
--ctp-blue: #7B4FD6;
|
||||
--ctp-sapphire: #5c35a0;
|
||||
--ctp-sky: #4FC3F7;
|
||||
--ctp-teal: #4CAF50;
|
||||
--ctp-green: #4CAF50;
|
||||
|
||||
--ctp-pink: #FF6B9D;
|
||||
--ctp-flamingo: #FF4488;
|
||||
--ctp-maroon: #b71c1c;
|
||||
--ctp-red: #e53935;
|
||||
|
||||
--bg-app: #1a1208;
|
||||
--bg-sidebar: #1e3a5f; /* Andy's sky-blue wallpaper */
|
||||
--bg-card: #2a1c10;
|
||||
--bg-hover: rgba(255, 214, 0, 0.10);
|
||||
--bg-player: #0d0a06;
|
||||
--bg-glass: rgba(26, 18, 8, 0.90);
|
||||
|
||||
--accent: #FFD600;
|
||||
--accent-dim: rgba(255, 214, 0, 0.15);
|
||||
--accent-glow: rgba(255, 214, 0, 0.40);
|
||||
--volume-accent: #7B4FD6; /* Buzz purple */
|
||||
|
||||
--text-primary: #f0e8d8;
|
||||
--text-secondary:#c8a878;
|
||||
--text-muted: #6a5030;
|
||||
--border: rgba(255, 214, 0, 0.22);
|
||||
--border-subtle: rgba(255, 214, 0, 0.10);
|
||||
--border-dropdown: rgba(255, 214, 0, 0.25);
|
||||
--shadow-dropdown: rgba(8, 6, 3, 0.92);
|
||||
|
||||
--positive: #4CAF50;
|
||||
--warning: #FFA000;
|
||||
--danger: #e53935;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
}
|
||||
|
||||
/* Sidebar — Andy's iconic cloud wallpaper in sky blue */
|
||||
[data-theme='toy-tale'] .sidebar {
|
||||
background:
|
||||
radial-gradient(ellipse 70px 42px at 18% 12%, rgba(255,255,255,0.14) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 45px 28px at 38% 18%, rgba(255,255,255,0.09) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 80px 50px at 72% 10%, rgba(255,255,255,0.12) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 55px 32px at 85% 20%, rgba(255,255,255,0.08) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 65px 40px at 12% 55%, rgba(255,255,255,0.10) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 48px 30px at 55% 68%, rgba(255,255,255,0.08) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 72px 44px at 80% 75%, rgba(255,255,255,0.11) 0%, transparent 100%),
|
||||
linear-gradient(180deg, #1e3a5f 0%, #274d78 100%);
|
||||
border-right: 1px solid rgba(255, 214, 0, 0.20);
|
||||
}
|
||||
|
||||
/* Nav links need lighter text on blue sidebar */
|
||||
[data-theme='toy-tale'] .nav-link {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .nav-link:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .nav-link.active {
|
||||
background: rgba(255, 214, 0, 0.20);
|
||||
border-left: 3px solid #FFD600;
|
||||
color: #FFD600 !important;
|
||||
text-shadow: 0 0 8px rgba(255, 214, 0, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .sidebar-logo,
|
||||
[data-theme='toy-tale'] .sidebar-title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Player bar — dark toy-chest wood */
|
||||
[data-theme='toy-tale'] .player-bar {
|
||||
background: linear-gradient(180deg, #1a1208 0%, #0d0a06 100%);
|
||||
border-top: 2px solid #FFD600;
|
||||
box-shadow: 0 -6px 20px rgba(255, 214, 0, 0.10);
|
||||
}
|
||||
|
||||
/* Track name — Woody sheriff star gold */
|
||||
[data-theme='toy-tale'] .player-track-name {
|
||||
color: #FFD600;
|
||||
text-shadow: 0 0 10px rgba(255, 214, 0, 0.45);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Artist name — warm cowboy tan */
|
||||
[data-theme='toy-tale'] .player-artist-name {
|
||||
color: #c8a060;
|
||||
}
|
||||
|
||||
/* Content header */
|
||||
[data-theme='toy-tale'] .content-header {
|
||||
border-bottom: 1px solid rgba(255, 214, 0, 0.18);
|
||||
}
|
||||
|
||||
/* Primary buttons — Woody yellow */
|
||||
[data-theme='toy-tale'] .btn-primary,
|
||||
[data-theme='toy-tale'] .player-btn-primary,
|
||||
[data-theme='toy-tale'] .hero-play-btn {
|
||||
background: linear-gradient(135deg, #FFD600 0%, #FFA000 100%);
|
||||
color: #1a1208 !important;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 14px rgba(255, 214, 0, 0.35);
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .btn-primary:hover,
|
||||
[data-theme='toy-tale'] .player-btn-primary:hover,
|
||||
[data-theme='toy-tale'] .hero-play-btn:hover {
|
||||
background: linear-gradient(135deg, #ffe033 0%, #FFD600 100%);
|
||||
box-shadow: 0 6px 20px rgba(255, 214, 0, 0.50);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
[data-theme='toy-tale'] .album-card,
|
||||
[data-theme='toy-tale'] .artist-card,
|
||||
[data-theme='toy-tale'] .card,
|
||||
[data-theme='toy-tale'] .settings-card {
|
||||
border: 1px solid rgba(255, 214, 0, 0.16);
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .album-card:hover,
|
||||
[data-theme='toy-tale'] .artist-card:hover {
|
||||
border-color: rgba(255, 214, 0, 0.42);
|
||||
box-shadow: 0 4px 18px rgba(255, 214, 0, 0.14);
|
||||
}
|
||||
|
||||
/* Track rows */
|
||||
[data-theme='toy-tale'] .track-row:hover,
|
||||
[data-theme='toy-tale'] .queue-item:hover {
|
||||
background: rgba(255, 214, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Queue active item — Buzz purple accent */
|
||||
[data-theme='toy-tale'] .queue-item.active {
|
||||
background: rgba(123, 79, 214, 0.12);
|
||||
border-left: 3px solid #7B4FD6;
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .queue-item.active .queue-item-title {
|
||||
color: #FFD600;
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .queue-item.active .queue-item-artist,
|
||||
[data-theme='toy-tale'] .queue-item.active .queue-item-duration {
|
||||
color: #9B72D6;
|
||||
}
|
||||
|
||||
/* Scrollbar — Woody brown */
|
||||
[data-theme='toy-tale'] ::-webkit-scrollbar { width: 6px; }
|
||||
[data-theme='toy-tale'] ::-webkit-scrollbar-track { background: #0d0a06; }
|
||||
[data-theme='toy-tale'] ::-webkit-scrollbar-thumb {
|
||||
background: #7a5a35;
|
||||
border-radius: 3px;
|
||||
}
|
||||
[data-theme='toy-tale'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #FFD600;
|
||||
}
|
||||
|
||||
/* Connection indicators */
|
||||
[data-theme='toy-tale'] .connection-type,
|
||||
[data-theme='toy-tale'] .connection-server { color: #6a5030; }
|
||||
|
||||
+87
-4
@@ -1,3 +1,5 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const DB_NAME = 'psysonic-img-cache';
|
||||
const STORE_NAME = 'images';
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
@@ -25,7 +27,7 @@ function releaseFetchSlot(): void {
|
||||
if (next) { activeFetches++; next(); }
|
||||
}
|
||||
|
||||
function evictIfNeeded(): void {
|
||||
function evictMemoryIfNeeded(): void {
|
||||
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
|
||||
const oldestKey = objectUrlCache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
@@ -73,6 +75,48 @@ async function getBlob(key: string): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Evicts oldest IDB entries until total blob size is below maxBytes. Fire-and-forget. */
|
||||
async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
|
||||
try {
|
||||
const database = await openDB();
|
||||
const entries: Array<{ key: string; timestamp: number; size: number }> = await new Promise(resolve => {
|
||||
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll();
|
||||
req.onsuccess = () => {
|
||||
resolve(
|
||||
(req.result ?? []).map((e: { key: string; timestamp: number; blob: Blob }) => ({
|
||||
key: e.key,
|
||||
timestamp: e.timestamp,
|
||||
size: e.blob?.size ?? 0,
|
||||
})),
|
||||
);
|
||||
};
|
||||
req.onerror = () => resolve([]);
|
||||
});
|
||||
|
||||
let total = entries.reduce((acc, e) => acc + e.size, 0);
|
||||
if (total <= maxBytes) return;
|
||||
|
||||
// Oldest first
|
||||
entries.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const entry of entries) {
|
||||
if (total <= maxBytes) break;
|
||||
store.delete(entry.key);
|
||||
// Also purge from memory cache
|
||||
const objUrl = objectUrlCache.get(entry.key);
|
||||
if (objUrl) {
|
||||
URL.revokeObjectURL(objUrl);
|
||||
objectUrlCache.delete(entry.key);
|
||||
}
|
||||
total -= entry.size;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function putBlob(key: string, blob: Blob): Promise<void> {
|
||||
try {
|
||||
const database = await openDB();
|
||||
@@ -82,11 +126,50 @@ async function putBlob(key: string, blob: Blob): Promise<void> {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
// Enforce disk limit after write (fire-and-forget)
|
||||
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
|
||||
evictDiskIfNeeded(maxBytes);
|
||||
} catch {
|
||||
// Ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the total size in bytes of all blobs stored in IndexedDB. */
|
||||
export async function getImageCacheSize(): Promise<number> {
|
||||
try {
|
||||
const database = await openDB();
|
||||
return new Promise(resolve => {
|
||||
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll();
|
||||
req.onsuccess = () => {
|
||||
const entries: Array<{ blob: Blob }> = req.result ?? [];
|
||||
resolve(entries.reduce((acc, e) => acc + (e.blob?.size ?? 0), 0));
|
||||
};
|
||||
req.onerror = () => resolve(0);
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
|
||||
export async function clearImageCache(): Promise<void> {
|
||||
for (const url of objectUrlCache.values()) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
objectUrlCache.clear();
|
||||
try {
|
||||
const database = await openDB();
|
||||
await new Promise<void>(resolve => {
|
||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cached object URL for an image.
|
||||
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
||||
@@ -104,7 +187,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictIfNeeded();
|
||||
evictMemoryIfNeeded();
|
||||
return objUrl;
|
||||
}
|
||||
|
||||
@@ -114,10 +197,10 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
const resp = await fetch(fetchUrl);
|
||||
if (!resp.ok) return fetchUrl;
|
||||
const newBlob = await resp.blob();
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
|
||||
const objUrl = URL.createObjectURL(newBlob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictIfNeeded();
|
||||
evictMemoryIfNeeded();
|
||||
return objUrl;
|
||||
} catch {
|
||||
return fetchUrl;
|
||||
|
||||
Reference in New Issue
Block a user