mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
perf(search): use CachedImage for result thumbs to stop re-render download storms (#245)
The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.
buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.
While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.
Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:
buildCoverArtUrl() generates a new ephemeral URL every call —
browser cache is useless. Use coverArtCacheKey(id, size) +
CachedImage for <img> tags.
Confirmed locally: typing in the search field is noticeably smoother.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
06140a490b
commit
9da1d643f7
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
|
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
|
||||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import CachedImage from './CachedImage';
|
||||||
|
|
||||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
@@ -166,7 +167,12 @@ export default function LiveSearch() {
|
|||||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||||
role="option" aria-selected={activeIndex === i}>
|
role="option" aria-selected={activeIndex === i}>
|
||||||
{a.coverArt ? (
|
{a.coverArt ? (
|
||||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
<CachedImage
|
||||||
|
className="search-result-thumb"
|
||||||
|
src={buildCoverArtUrl(a.coverArt, 40)}
|
||||||
|
cacheKey={coverArtCacheKey(a.coverArt, 40)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import CachedImage from './CachedImage';
|
||||||
|
|
||||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||||
const MAX_RECENT = 6;
|
const MAX_RECENT = 6;
|
||||||
@@ -203,7 +204,12 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
{results!.albums.map(a => (
|
{results!.albums.map(a => (
|
||||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
||||||
{a.coverArt ? (
|
{a.coverArt ? (
|
||||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
|
<CachedImage
|
||||||
|
className="mobile-search-thumb"
|
||||||
|
src={buildCoverArtUrl(a.coverArt, 80)}
|
||||||
|
cacheKey={coverArtCacheKey(a.coverArt, 80)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mobile-search-avatar">
|
<div className="mobile-search-avatar">
|
||||||
<Disc3 size={20} />
|
<Disc3 size={20} />
|
||||||
@@ -225,7 +231,12 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
{results!.songs.map(s => (
|
{results!.songs.map(s => (
|
||||||
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
|
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
|
||||||
{s.coverArt ? (
|
{s.coverArt ? (
|
||||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
|
<CachedImage
|
||||||
|
className="mobile-search-thumb"
|
||||||
|
src={buildCoverArtUrl(s.coverArt, 80)}
|
||||||
|
cacheKey={coverArtCacheKey(s.coverArt, 80)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mobile-search-avatar">
|
<div className="mobile-search-avatar">
|
||||||
<Music size={20} />
|
<Music size={20} />
|
||||||
|
|||||||
Reference in New Issue
Block a user