mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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:
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user