mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
Perf/UI cover cache mainstage (#468)
* Enhance CachedImage and ArtistDetail components with improved image caching and priority handling - Refactor CachedImage to utilize a priority system for image loading based on viewport visibility, improving performance during scrolling. - Update useCachedUrl to accept an optional getPriority function for better cache management. - Optimize ArtistDetail and Artists components by using useMemo for cover art URLs, reducing redundant calculations and improving rendering efficiency. - Adjust image loading logic in CachedImage to ensure smoother transitions and avoid unnecessary fetch requests. * perf(ui): unblock IDB cover art, stabilize mainstage rails and virtual lists Let IndexedDB reads bypass the network concurrency slot so cached thumbnails paint without queueing behind remote fetches; debounce disk eviction during heavy scrolling. Fix mainstage horizontal rails: dedupe album/song ids for React keys, widen artwork budget overscan, avoid resetting the budget on list append, and raise Home initial artwork budgets. CachedImage treats already-decoded images as loaded; rail cards load cover images eagerly. Refresh dynamic color extraction and extend virtual scrolling / scroll roots on Albums, Artists, Playlists, and related surfaces. Remove local agent-only commit instructions from the repository tree. * perf(virtual): viewport-based overscan for main scroll lists Drive TanStack Virtual overscan from measured scroll height so each list renders about one screen of extra rows above and below the viewport for snappier scrolling on Albums, Artists (list mode), and Tracks virtual song list. Introduce useResizeClientHeight helpers (ID + ref) for ResizeObserver-based clientHeight tracking. * docs(changelog): note PR #468 UI cover cache, rails, and virtual lists Add a coarse summary under 1.46.0 Changed for cover-art pipeline, mainstage rails, viewport-based overscan, and library/chrome polish.
This commit is contained in:
@@ -36,6 +36,9 @@ src-tauri/gen/
|
|||||||
# Documentation
|
# Documentation
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Local commit-instructions for agents (never commit)
|
||||||
|
to_commit.md
|
||||||
|
|
||||||
# Claude Code memory (local only)
|
# Claude Code memory (local only)
|
||||||
memory/
|
memory/
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
* Heavier app **routes** are loaded **lazily** so the initial JS bundle stays smaller (`App.tsx` and related entry wiring).
|
* Heavier app **routes** are loaded **lazily** so the initial JS bundle stays smaller (`App.tsx` and related entry wiring).
|
||||||
* Restored **default Vite `chunkSizeWarningLimit`** behaviour so oversized chunks are reported again during production builds (`vite.config.ts`).
|
* Restored **default Vite `chunkSizeWarningLimit`** behaviour so oversized chunks are reported again during production builds (`vite.config.ts`).
|
||||||
|
|
||||||
|
### UI — cover cache, mainstage rails, and smoother virtual lists
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#468](https://github.com/Psychotoxical/psysonic/pull/468)**
|
||||||
|
|
||||||
|
* **Image cache:** **Network fetches** share a small concurrency pool; **IndexedDB** hits are no longer queued behind remote downloads. **Debounced** disk eviction avoids hammering storage during fast cover scrolling. Related **shared blob URL** / hot-path fixes for thumbnails.
|
||||||
|
* **Mainstage & rails:** **Horizontal rows** use **reworked artwork windowing** (higher initial budgets, extra slack ahead of the viewport, **no budget reset** when “load more” extends the list). **Duplicate album/song IDs** from the API are **deduped** for stable React reconciliation. **CachedImage** handles **already-decoded / cache-hit** images cleanly; rail **Album / song cards** load covers **eagerly** to reduce **blank art** while scrubbing sideways.
|
||||||
|
* **Virtualised lists:** **Albums**, **Artists** (list mode), and the **Tracks** virtual song browser **derive** **TanStack Virtual `overscan`** from the **measured scroll viewport** (~ **one screen** of extra rows above and below) instead of a tiny fixed cushion.
|
||||||
|
* **Library & chrome:** assorted **scroll / layout** improvements on **Artist detail**, **Playlists**, **Most played**; smaller touch-ups to **Mini player**, **Live search**, **Album header**, and **dynamic colour** extraction used by player / album surfaces.
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
### Hot cache, HTTP streaming replay, and queue source indicator
|
### Hot cache, HTTP streaming replay, and queue source indicator
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function AlbumCard({
|
|||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
cacheKey={coverCacheKey}
|
cacheKey={coverCacheKey}
|
||||||
alt={`${album.name} Cover`}
|
alt={`${album.name} Cover`}
|
||||||
loading="lazy"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle, Share2 } from 'lucide-react';
|
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle, Share2 } from 'lucide-react';
|
||||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||||
@@ -135,6 +135,11 @@ export default function AlbumHeader({
|
|||||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||||
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
||||||
|
|
||||||
|
const lightboxCoverSrc = useMemo(
|
||||||
|
() => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''),
|
||||||
|
[info.coverArt],
|
||||||
|
);
|
||||||
|
|
||||||
const handleShareAlbum = async () => {
|
const handleShareAlbum = async () => {
|
||||||
try {
|
try {
|
||||||
const ok = await copyEntityShareLink('album', info.id);
|
const ok = await copyEntityShareLink('album', info.id);
|
||||||
@@ -150,7 +155,7 @@ export default function AlbumHeader({
|
|||||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||||
{lightboxOpen && info.coverArt && (
|
{lightboxOpen && info.coverArt && (
|
||||||
<CoverLightbox
|
<CoverLightbox
|
||||||
src={buildCoverArtUrl(info.coverArt, 2000)}
|
src={lightboxCoverSrc}
|
||||||
alt={`${info.name} Cover`}
|
alt={`${info.name} Cover`}
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClose={() => setLightboxOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+23
-16
@@ -1,10 +1,11 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect, useMemo } from 'react';
|
||||||
import { SubsonicAlbum } from '../api/subsonic';
|
import { SubsonicAlbum } from '../api/subsonic';
|
||||||
import AlbumCard from './AlbumCard';
|
import AlbumCard from './AlbumCard';
|
||||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
import { dedupeById } from '../utils/dedupeById';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -50,6 +51,7 @@ export default function AlbumRow({
|
|||||||
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
const uniqueAlbums = useMemo(() => dedupeById(albums), [albums]);
|
||||||
|
|
||||||
const recomputeArtworkBudget = () => {
|
const recomputeArtworkBudget = () => {
|
||||||
if (!windowArtworkByViewport) return;
|
if (!windowArtworkByViewport) return;
|
||||||
@@ -62,19 +64,23 @@ export default function AlbumRow({
|
|||||||
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
|
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
|
||||||
const step = Math.max(1, cardW + gap);
|
const step = Math.max(1, cardW + gap);
|
||||||
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
|
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
|
||||||
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
|
// Extra slack so fast horizontal scroll doesn’t hit the idx≥budget cliff between frames.
|
||||||
|
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12);
|
||||||
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
|
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (interactivityDisabled) return;
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||||
|
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||||
setShowLeft(scrollLeft > 0);
|
|
||||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
|
||||||
recomputeArtworkBudget();
|
|
||||||
|
|
||||||
// Auto-load trigger
|
if (!interactivityDisabled) {
|
||||||
|
setShowLeft(scrollLeft > 0);
|
||||||
|
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-load trigger (native horizontal scroll still works when rail buttons are perf-disabled)
|
||||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||||
triggerLoadMore();
|
triggerLoadMore();
|
||||||
}
|
}
|
||||||
@@ -90,15 +96,13 @@ export default function AlbumRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (interactivityDisabled) return;
|
|
||||||
handleScroll();
|
handleScroll();
|
||||||
const raf = window.requestAnimationFrame(() => {
|
const raf = window.requestAnimationFrame(() => {
|
||||||
// One post-layout pass ensures we account for final grid/card geometry.
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||||
recomputeArtworkBudget();
|
|
||||||
});
|
});
|
||||||
window.addEventListener('resize', handleScroll);
|
window.addEventListener('resize', handleScroll);
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
recomputeArtworkBudget();
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||||
});
|
});
|
||||||
if (scrollRef.current) ro.observe(scrollRef.current);
|
if (scrollRef.current) ro.observe(scrollRef.current);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -106,11 +110,14 @@ export default function AlbumRow({
|
|||||||
window.removeEventListener('resize', handleScroll);
|
window.removeEventListener('resize', handleScroll);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
}, [albums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
}, [uniqueAlbums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||||
|
|
||||||
|
// Reset when the row’s identity changes (new data / server), not when the list grows via
|
||||||
|
// “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders.
|
||||||
|
const rowArtworkResetKey = uniqueAlbums[0]?.id ?? '';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setArtworkBudget(initialArtworkBudget);
|
setArtworkBudget(initialArtworkBudget);
|
||||||
}, [initialArtworkBudget, albums.length]);
|
}, [initialArtworkBudget, rowArtworkResetKey]);
|
||||||
|
|
||||||
const scroll = (dir: 'left' | 'right') => {
|
const scroll = (dir: 'left' | 'right') => {
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
@@ -118,7 +125,7 @@ export default function AlbumRow({
|
|||||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (albums.length === 0) return null;
|
if (uniqueAlbums.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="album-row-section">
|
<section className="album-row-section">
|
||||||
@@ -154,8 +161,8 @@ export default function AlbumRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="album-grid-wrapper">
|
<div className="album-grid-wrapper">
|
||||||
<div className="album-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
|
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||||
{albums.map((a, idx) => (
|
{uniqueAlbums.map((a, idx) => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={a.id}
|
key={a.id}
|
||||||
album={a}
|
album={a}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||||
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
|
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
|
||||||
|
|
||||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
@@ -17,7 +18,19 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|||||||
* loading immediately. Pass false for CSS background-image consumers that
|
* loading immediately. Pass false for CSS background-image consumers that
|
||||||
* should only see a stable blob URL (prevents a double crossfade).
|
* should only see a stable blob URL (prevents a double crossfade).
|
||||||
*/
|
*/
|
||||||
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
|
export function useCachedUrl(
|
||||||
|
fetchUrl: string,
|
||||||
|
cacheKey: string,
|
||||||
|
fallbackToFetch = true,
|
||||||
|
getPriority?: () => number,
|
||||||
|
): string {
|
||||||
|
// `buildCoverArtUrl` rotates salt/token on every call — `fetchUrl` is a new
|
||||||
|
// string each render though the logical image is unchanged (`cacheKey`). If
|
||||||
|
// `fetchUrl` were an effect dependency, cleanup would run every frame, call
|
||||||
|
// `releaseUrl`, revoke the blob, and break <img> until onError hides it.
|
||||||
|
const fetchUrlRef = useRef(fetchUrl);
|
||||||
|
fetchUrlRef.current = fetchUrl;
|
||||||
|
|
||||||
// Synchronously acquire on first render when the blob is already hot. This
|
// Synchronously acquire on first render when the blob is already hot. This
|
||||||
// makes the very first <img src> a blob URL, avoiding a fetchUrl→blobUrl
|
// makes the very first <img src> a blob URL, avoiding a fetchUrl→blobUrl
|
||||||
// swap that would trigger a redundant network request and decode pass.
|
// swap that would trigger a redundant network request and decode pass.
|
||||||
@@ -26,6 +39,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
|||||||
// exactly what to release on cleanup or when keys change.
|
// exactly what to release on cleanup or when keys change.
|
||||||
const ownedKeyRef = useRef<string | null>(resolved ? cacheKey : null);
|
const ownedKeyRef = useRef<string | null>(resolved ? cacheKey : null);
|
||||||
|
|
||||||
|
const getPriorityRef = useRef(getPriority);
|
||||||
|
getPriorityRef.current = getPriority;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const release = () => {
|
const release = () => {
|
||||||
if (ownedKeyRef.current) {
|
if (ownedKeyRef.current) {
|
||||||
@@ -34,14 +50,17 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!fetchUrl) {
|
const currentUrl = fetchUrlRef.current;
|
||||||
|
if (!currentUrl) {
|
||||||
release();
|
release();
|
||||||
setResolved('');
|
setResolved('');
|
||||||
return;
|
return release;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy initializer (or a previous run) already acquired the right key.
|
// Same logical image as last run — only `cacheKey` drives this effect.
|
||||||
if (ownedKeyRef.current === cacheKey) return release;
|
if (ownedKeyRef.current === cacheKey) {
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
// Different key than we're currently holding: drop the old one.
|
// Different key than we're currently holding: drop the old one.
|
||||||
release();
|
release();
|
||||||
@@ -57,7 +76,7 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
|||||||
// Slow path: fetch (or read from IDB), then acquire.
|
// Slow path: fetch (or read from IDB), then acquire.
|
||||||
setResolved('');
|
setResolved('');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => {
|
getCachedBlob(currentUrl, cacheKey, controller.signal, () => getPriorityRef.current?.() ?? 0).then(blob => {
|
||||||
if (controller.signal.aborted || !blob) return;
|
if (controller.signal.aborted || !blob) return;
|
||||||
const url = acquireUrl(cacheKey);
|
const url = acquireUrl(cacheKey);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -68,32 +87,59 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
release();
|
release();
|
||||||
};
|
};
|
||||||
}, [fetchUrl, cacheKey]);
|
}, [cacheKey]);
|
||||||
|
|
||||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
|
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
|
||||||
const [inView, setInView] = useState(false);
|
|
||||||
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
|
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
/**
|
||||||
|
* Drives disk/network waiter ordering only. We intentionally do **not** gate
|
||||||
|
* `useCachedUrl` on intersection — relying on IO to “arm” loading proved brittle
|
||||||
|
* (custom scroll roots, content-visibility, horizontal rails) and led to blank covers.
|
||||||
|
*/
|
||||||
|
const priorityRef = useRef(0);
|
||||||
|
const getViewportImagePriority = useCallback(() => priorityRef.current, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = imgRef.current;
|
const el = imgRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
const root =
|
||||||
|
typeof document !== 'undefined'
|
||||||
|
? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as Element | null)
|
||||||
|
: null;
|
||||||
|
const updateFromEntry = (entry: IntersectionObserverEntry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const r = entry.boundingClientRect;
|
||||||
|
const rootEl = entry.rootBounds;
|
||||||
|
const vh = (rootEl?.height ?? window.innerHeight) || 1;
|
||||||
|
const originTop = rootEl?.top ?? 0;
|
||||||
|
const vc = originTop + vh * 0.5;
|
||||||
|
const cy = r.top + r.height * 0.5;
|
||||||
|
const dist = Math.abs(cy - vc);
|
||||||
|
priorityRef.current = entry.intersectionRatio * 1e7 - dist * 1e3;
|
||||||
|
} else {
|
||||||
|
priorityRef.current = -1e12;
|
||||||
|
}
|
||||||
|
};
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
|
entries => { for (const e of entries) updateFromEntry(e); },
|
||||||
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
|
{
|
||||||
|
root: root ?? undefined,
|
||||||
|
rootMargin: '300px',
|
||||||
|
threshold: [0, 0.02, 0.1, 0.25, 0.5, 0.75, 1],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
// Same as Hero/PlayerBar: show the salted fetch URL while IndexedDB/network resolves,
|
||||||
// fallbackToFetch=false: avoid the fetchUrl→blobUrl src swap, which causes the browser
|
// then swap to the shared blob URL — avoids an <img> with no src and opacity stuck at 0.
|
||||||
// to start a server fetch, then abort it when we replace src with the blob URL —
|
// Priority still applies to the slow path inside getCachedBlob.
|
||||||
// visible in DevTools as a flood of "Pending / 0 B" requests on Chromium/WebView2.
|
const resolvedSrc = useCachedUrl(src, cacheKey, true, getViewportImagePriority);
|
||||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey, false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||||
@@ -103,6 +149,26 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
|||||||
setFallbackSrc(undefined);
|
setFallbackSrc(undefined);
|
||||||
}, [cacheKey]);
|
}, [cacheKey]);
|
||||||
|
|
||||||
|
const isFallback = fallbackSrc !== undefined;
|
||||||
|
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
|
||||||
|
|
||||||
|
// Browsers sometimes skip `load` for cache hits / lazy + horizontal scroll — unstick opacity.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!finalSrc) return;
|
||||||
|
let alive = true;
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
if (!alive) return;
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (img?.complete && img.naturalWidth > 0) {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
cancelAnimationFrame(id);
|
||||||
|
};
|
||||||
|
}, [finalSrc]);
|
||||||
|
|
||||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
// Caller wants custom error handling (e.g. hide the element)
|
// Caller wants custom error handling (e.g. hide the element)
|
||||||
@@ -114,9 +180,6 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFallback = fallbackSrc !== undefined;
|
|
||||||
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
|
|
||||||
|
|
||||||
const fallbackStyle: React.CSSProperties = isFallback
|
const fallbackStyle: React.CSSProperties = isFallback
|
||||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||||
: {};
|
: {};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react';
|
import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react';
|
||||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
@@ -16,6 +16,12 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
|
||||||
|
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
|
||||||
|
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]);
|
||||||
|
return <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LiveSearch() {
|
export default function LiveSearch() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -323,12 +329,7 @@ export default function LiveSearch() {
|
|||||||
}}
|
}}
|
||||||
role="option" aria-selected={activeIndex === i}>
|
role="option" aria-selected={activeIndex === i}>
|
||||||
{a.coverArt ? (
|
{a.coverArt ? (
|
||||||
<CachedImage
|
<LiveSearchAlbumThumb coverArt={a.coverArt} />
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { emit, listen } from '@tauri-apps/api/event';
|
import { emit, listen } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -481,6 +481,14 @@ export default function MiniPlayer() {
|
|||||||
}, [queueOpen, state.queueIndex]);
|
}, [queueOpen, state.queueIndex]);
|
||||||
|
|
||||||
const { track, isPlaying } = state;
|
const { track, isPlaying } = state;
|
||||||
|
const miniCoverSrc = useMemo(
|
||||||
|
() => (track?.coverArt ? buildCoverArtUrl(track.coverArt, 300) : ''),
|
||||||
|
[track?.coverArt],
|
||||||
|
);
|
||||||
|
const miniCoverKey = useMemo(
|
||||||
|
() => (track?.coverArt ? coverArtCacheKey(track.coverArt, 300) : ''),
|
||||||
|
[track?.coverArt],
|
||||||
|
);
|
||||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -540,8 +548,8 @@ export default function MiniPlayer() {
|
|||||||
<div className="mini-player__art">
|
<div className="mini-player__art">
|
||||||
{track?.coverArt ? (
|
{track?.coverArt ? (
|
||||||
<CachedImage
|
<CachedImage
|
||||||
src={buildCoverArtUrl(track.coverArt, 300)}
|
src={miniCoverSrc}
|
||||||
cacheKey={coverArtCacheKey(track.coverArt, 300)}
|
cacheKey={miniCoverKey}
|
||||||
alt={track.album}
|
alt={track.album}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardP
|
|||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
cacheKey={coverCacheKey}
|
cacheKey={coverCacheKey}
|
||||||
alt={`${song.album} Cover`}
|
alt={`${song.album} Cover`}
|
||||||
loading="lazy"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+20
-16
@@ -1,8 +1,9 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect, useMemo } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
||||||
import { SubsonicSong } from '../api/subsonic';
|
import { SubsonicSong } from '../api/subsonic';
|
||||||
import SongCard from './SongCard';
|
import SongCard from './SongCard';
|
||||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
import { dedupeById } from '../utils/dedupeById';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -36,6 +37,7 @@ export default function SongRail({
|
|||||||
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
||||||
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
|
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const uniqueSongs = useMemo(() => dedupeById(songs), [songs]);
|
||||||
const [showLeft, setShowLeft] = useState(false);
|
const [showLeft, setShowLeft] = useState(false);
|
||||||
const [showRight, setShowRight] = useState(true);
|
const [showRight, setShowRight] = useState(true);
|
||||||
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
||||||
@@ -51,29 +53,30 @@ export default function SongRail({
|
|||||||
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
|
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
|
||||||
const step = Math.max(1, cardW + gap);
|
const step = Math.max(1, cardW + gap);
|
||||||
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
|
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
|
||||||
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
|
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12);
|
||||||
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
|
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (interactivityDisabled) return;
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||||
|
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||||
setShowLeft(scrollLeft > 0);
|
|
||||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
if (!interactivityDisabled) {
|
||||||
recomputeArtworkBudget();
|
setShowLeft(scrollLeft > 0);
|
||||||
|
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (interactivityDisabled) return;
|
|
||||||
handleScroll();
|
handleScroll();
|
||||||
const raf = window.requestAnimationFrame(() => {
|
const raf = window.requestAnimationFrame(() => {
|
||||||
// One post-layout pass ensures we account for final grid/card geometry.
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||||
recomputeArtworkBudget();
|
|
||||||
});
|
});
|
||||||
window.addEventListener('resize', handleScroll);
|
window.addEventListener('resize', handleScroll);
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
recomputeArtworkBudget();
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||||
});
|
});
|
||||||
if (scrollRef.current) ro.observe(scrollRef.current);
|
if (scrollRef.current) ro.observe(scrollRef.current);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -81,11 +84,12 @@ export default function SongRail({
|
|||||||
window.removeEventListener('resize', handleScroll);
|
window.removeEventListener('resize', handleScroll);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
}, [songs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
}, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||||
|
|
||||||
|
const rowArtworkResetKey = uniqueSongs[0]?.id ?? '';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setArtworkBudget(initialArtworkBudget);
|
setArtworkBudget(initialArtworkBudget);
|
||||||
}, [initialArtworkBudget, songs.length]);
|
}, [initialArtworkBudget, rowArtworkResetKey]);
|
||||||
|
|
||||||
const scroll = (dir: 'left' | 'right') => {
|
const scroll = (dir: 'left' | 'right') => {
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
@@ -94,7 +98,7 @@ export default function SongRail({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Hide rail entirely if empty and no empty-state copy
|
// Hide rail entirely if empty and no empty-state copy
|
||||||
if (songs.length === 0 && !loading && !emptyText) return null;
|
if (uniqueSongs.length === 0 && !loading && !emptyText) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="song-row-section">
|
<section className="song-row-section">
|
||||||
@@ -135,11 +139,11 @@ export default function SongRail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="song-grid-wrapper">
|
<div className="song-grid-wrapper">
|
||||||
{songs.length === 0 && emptyText ? (
|
{uniqueSongs.length === 0 && emptyText ? (
|
||||||
<p className="song-row-empty">{emptyText}</p>
|
<p className="song-row-empty">{emptyText}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="song-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
|
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||||
{songs.map((s, idx) => (
|
{uniqueSongs.map((s, idx) => (
|
||||||
<SongCard
|
<SongCard
|
||||||
key={s.id}
|
key={s.id}
|
||||||
song={s}
|
song={s}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useRefElementClientHeight } from '../hooks/useResizeClientHeight';
|
||||||
import { Search as SearchIcon, X } from 'lucide-react';
|
import { Search as SearchIcon, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
@@ -43,6 +44,8 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
|||||||
const [browseUnsupported, setBrowseUnsupported] = useState(false);
|
const [browseUnsupported, setBrowseUnsupported] = useState(false);
|
||||||
|
|
||||||
const scrollParentRef = useRef<HTMLDivElement>(null);
|
const scrollParentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollParentHeight = useRefElementClientHeight(scrollParentRef);
|
||||||
|
const songListOverscan = Math.max(8, Math.ceil(scrollParentHeight / ROW_HEIGHT));
|
||||||
const requestSeqRef = useRef(0);
|
const requestSeqRef = useRef(0);
|
||||||
|
|
||||||
// Debounce query
|
// Debounce query
|
||||||
@@ -139,7 +142,7 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
|||||||
count: songs.length,
|
count: songs.length,
|
||||||
getScrollElement: () => scrollParentRef.current,
|
getScrollElement: () => scrollParentRef.current,
|
||||||
estimateSize: () => ROW_HEIGHT,
|
estimateSize: () => ROW_HEIGHT,
|
||||||
overscan: 8,
|
overscan: songListOverscan,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalSize = virtualizer.getTotalSize();
|
const totalSize = virtualizer.getTotalSize();
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { type RefObject, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track an element's `clientHeight` (ResizeObserver). Used so virtualizers can
|
||||||
|
* set `overscan` to roughly one viewport of rows beyond the visible range.
|
||||||
|
*/
|
||||||
|
export function useElementClientHeightById(elementId: string, fallback = 800): number {
|
||||||
|
const [h, setH] = useState(fallback);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = typeof document !== 'undefined' ? document.getElementById(elementId) : null;
|
||||||
|
if (!el) {
|
||||||
|
setH(fallback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const update = () => setH(el.clientHeight);
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(el);
|
||||||
|
update();
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [elementId, fallback]);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefElementClientHeight(
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
fallback = 600,
|
||||||
|
): number {
|
||||||
|
const [h, setH] = useState(fallback);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => setH(el.clientHeight);
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(el);
|
||||||
|
update();
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [ref, fallback]);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
+101
-13
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
import YearFilterButton from '../components/YearFilterButton';
|
import YearFilterButton from '../components/YearFilterButton';
|
||||||
@@ -16,8 +16,16 @@ import { join } from '@tauri-apps/api/path';
|
|||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
|
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||||
|
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
|
||||||
|
const ALBUM_GRID_GAP_PX = 16; // matches --space-4
|
||||||
|
const ALBUM_GRID_MIN_CARD_PX = 140;
|
||||||
|
/** Estimated row height for virtual window (card + margin). */
|
||||||
|
const ALBUM_VIRTUAL_ROW_HEIGHT = 288;
|
||||||
|
|
||||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||||
type CompFilter = 'all' | 'only' | 'hide';
|
type CompFilter = 'all' | 'only' | 'hide';
|
||||||
|
|
||||||
@@ -87,6 +95,38 @@ export default function Albums() {
|
|||||||
return out;
|
return out;
|
||||||
}, [albums, compFilter, starredOnly, starredOverrides]);
|
}, [albums, compFilter, starredOnly, starredOverrides]);
|
||||||
|
|
||||||
|
const albumGridWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [albumGridCols, setAlbumGridCols] = useState(4);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (perfFlags.disableMainstageVirtualLists) return;
|
||||||
|
const el = albumGridWrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const w = el.clientWidth;
|
||||||
|
const cols = Math.max(1, Math.floor((w + ALBUM_GRID_GAP_PX) / (ALBUM_GRID_MIN_CARD_PX + ALBUM_GRID_GAP_PX)));
|
||||||
|
setAlbumGridCols(cols);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [perfFlags.disableMainstageVirtualLists, visibleAlbums.length]);
|
||||||
|
|
||||||
|
const albumVirtualRowCount = Math.max(0, Math.ceil(visibleAlbums.length / albumGridCols));
|
||||||
|
|
||||||
|
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||||
|
/** ~One full viewport of grid rows above + below visible range (TanStack overscan = rows per side). */
|
||||||
|
const albumGridOverscan = Math.max(
|
||||||
|
2,
|
||||||
|
Math.ceil(mainScrollViewportHeight / ALBUM_VIRTUAL_ROW_HEIGHT),
|
||||||
|
);
|
||||||
|
|
||||||
|
const albumGridVirtualizer = useVirtualizer({
|
||||||
|
count: perfFlags.disableMainstageVirtualLists ? 0 : albumVirtualRowCount,
|
||||||
|
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
|
||||||
|
estimateSize: () => ALBUM_VIRTUAL_ROW_HEIGHT,
|
||||||
|
overscan: albumGridOverscan,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
|
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
|
||||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
const enqueue = usePlayerStore(state => state.enqueue);
|
const enqueue = usePlayerStore(state => state.enqueue);
|
||||||
@@ -313,18 +353,66 @@ export default function Albums() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!perfFlags.disableMainstageGridCards && (
|
{!perfFlags.disableMainstageGridCards && (
|
||||||
<div className="album-grid-wrap">
|
perfFlags.disableMainstageVirtualLists ? (
|
||||||
{visibleAlbums.map(a => (
|
<div className="album-grid-wrap">
|
||||||
<AlbumCard
|
{visibleAlbums.map(a => (
|
||||||
key={a.id}
|
<AlbumCard
|
||||||
album={a}
|
key={a.id}
|
||||||
selectionMode={selectionMode}
|
album={a}
|
||||||
selected={selectedIds.has(a.id)}
|
selectionMode={selectionMode}
|
||||||
onToggleSelect={toggleSelect}
|
selected={selectedIds.has(a.id)}
|
||||||
selectedAlbums={selectedAlbums}
|
onToggleSelect={toggleSelect}
|
||||||
/>
|
selectedAlbums={selectedAlbums}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={albumGridWrapRef}
|
||||||
|
className="album-grid-wrap"
|
||||||
|
style={{ display: 'block', position: 'relative', width: '100%' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: albumVirtualRowCount === 0 ? 0 : albumGridVirtualizer.getTotalSize(),
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{albumGridVirtualizer.getVirtualItems().map(vRow => {
|
||||||
|
const start = vRow.index * albumGridCols;
|
||||||
|
const rowAlbums = visibleAlbums.slice(start, start + albumGridCols);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vRow.key}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vRow.start}px)`,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${albumGridCols}, minmax(0, 1fr))`,
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowAlbums.map(a => (
|
||||||
|
<AlbumCard
|
||||||
|
key={a.id}
|
||||||
|
album={a}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
selected={selectedIds.has(a.id)}
|
||||||
|
onToggleSelect={toggleSelect}
|
||||||
|
selectedAlbums={selectedAlbums}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{!genreFiltered && (
|
{!genreFiltered && (
|
||||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
|||||||
+61
-25
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, Fragment } from 'react';
|
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
@@ -29,6 +29,20 @@ function formatDuration(seconds: number): string {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) {
|
||||||
|
const src = useMemo(() => buildCoverArtUrl(coverArt, 64), [coverArt]);
|
||||||
|
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 64), [coverArt]);
|
||||||
|
return (
|
||||||
|
<CachedImage
|
||||||
|
src={src}
|
||||||
|
cacheKey={cacheKey}
|
||||||
|
alt={album}
|
||||||
|
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Strip dangerous tags/attributes from server-provided HTML */
|
/** Strip dangerous tags/attributes from server-provided HTML */
|
||||||
function sanitizeHtml(html: string): string {
|
function sanitizeHtml(html: string): string {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
@@ -72,6 +86,8 @@ export default function ArtistDetail() {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [coverRevision, setCoverRevision] = useState(0);
|
const [coverRevision, setCoverRevision] = useState(0);
|
||||||
const [avatarGlow, setAvatarGlow] = useState('');
|
const [avatarGlow, setAvatarGlow] = useState('');
|
||||||
|
/** True after header CachedImage onError — avoid `display:none` on the img (breaks recovery). */
|
||||||
|
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const playTrack = usePlayerStore(state => state.playTrack);
|
const playTrack = usePlayerStore(state => state.playTrack);
|
||||||
@@ -424,6 +440,29 @@ export default function ArtistDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cover URLs — must run every render (before early returns) or hook order breaks.
|
||||||
|
const coverId = artist ? (artist.coverArt || artist.id) : '';
|
||||||
|
const artistCover300Src = useMemo(
|
||||||
|
() => (coverId ? buildCoverArtUrl(coverId, 300) : ''),
|
||||||
|
[coverId],
|
||||||
|
);
|
||||||
|
const artistCover300Key = useMemo(
|
||||||
|
() => (coverId ? coverArtCacheKey(coverId, 300) : ''),
|
||||||
|
[coverId],
|
||||||
|
);
|
||||||
|
const artistCover2000Src = useMemo(
|
||||||
|
() => (coverId ? buildCoverArtUrl(coverId, 2000) : ''),
|
||||||
|
[coverId],
|
||||||
|
);
|
||||||
|
const artistCover80FallbackSrc = useMemo(
|
||||||
|
() => (coverId ? buildCoverArtUrl(coverId, 80) : ''),
|
||||||
|
[coverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHeaderCoverFailed(false);
|
||||||
|
}, [coverId, coverRevision, id]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||||
@@ -442,7 +481,6 @@ export default function ArtistDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverId = artist.coverArt || artist.id;
|
|
||||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||||
|
|
||||||
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
|
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
|
||||||
@@ -489,7 +527,7 @@ export default function ArtistDetail() {
|
|||||||
|
|
||||||
{lightboxOpen && (
|
{lightboxOpen && (
|
||||||
<CoverLightbox
|
<CoverLightbox
|
||||||
src={buildCoverArtUrl(coverId, 2000)}
|
src={artistCover2000Src}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClose={() => setLightboxOpen(false)}
|
||||||
/>
|
/>
|
||||||
@@ -510,15 +548,19 @@ export default function ArtistDetail() {
|
|||||||
onClick={() => setLightboxOpen(true)}
|
onClick={() => setLightboxOpen(true)}
|
||||||
aria-label={`${artist.name} Bild vergrößern`}
|
aria-label={`${artist.name} Bild vergrößern`}
|
||||||
>
|
>
|
||||||
<CachedImage
|
{!headerCoverFailed ? (
|
||||||
key={coverRevision}
|
<CachedImage
|
||||||
src={buildCoverArtUrl(coverId, 300)}
|
key={coverRevision}
|
||||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
src={artistCover300Src}
|
||||||
alt={artist.name}
|
cacheKey={artistCover300Key}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
alt={artist.name}
|
||||||
onLoad={e => extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
onLoad={e => extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })}
|
||||||
/>
|
onError={() => setHeaderCoverFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Users size={64} color="var(--text-muted)" style={{ margin: 'auto', display: 'block' }} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Users size={64} color="var(--text-muted)" />
|
<Users size={64} color="var(--text-muted)" />
|
||||||
@@ -667,7 +709,7 @@ export default function ArtistDetail() {
|
|||||||
<div className="np-artist-bio-row">
|
<div className="np-artist-bio-row">
|
||||||
{(info?.largeImageUrl || coverId) && (
|
{(info?.largeImageUrl || coverId) && (
|
||||||
<img
|
<img
|
||||||
src={info?.largeImageUrl || buildCoverArtUrl(coverId, 80)}
|
src={info?.largeImageUrl || artistCover80FallbackSrc}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
className="np-artist-thumb"
|
className="np-artist-thumb"
|
||||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
@@ -702,7 +744,7 @@ export default function ArtistDetail() {
|
|||||||
const track = songToTrack(song);
|
const track = songToTrack(song);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={song.id}
|
key={`${song.id}-${idx}`}
|
||||||
className="track-row track-row-with-actions"
|
className="track-row track-row-with-actions"
|
||||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@@ -752,13 +794,7 @@ export default function ArtistDetail() {
|
|||||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||||
</button>
|
</button>
|
||||||
{song.coverArt && (
|
{song.coverArt && (
|
||||||
<CachedImage
|
<ArtistSuggestionTrackCover coverArt={song.coverArt} album={song.album} />
|
||||||
src={buildCoverArtUrl(song.coverArt, 64)}
|
|
||||||
cacheKey={coverArtCacheKey(song.coverArt, 64)}
|
|
||||||
alt={song.album}
|
|
||||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
|
||||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
<div className="track-title">{song.title}</div>
|
<div className="track-title">{song.title}</div>
|
||||||
@@ -802,9 +838,9 @@ export default function ArtistDetail() {
|
|||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
|
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
|
||||||
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
|
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
|
||||||
.map(a => (
|
.map((a, i) => (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={`${a.id}-${i}`}
|
||||||
className="artist-ext-link"
|
className="artist-ext-link"
|
||||||
onClick={() => navigate(`/artist/${a.id}`)}
|
onClick={() => navigate(`/artist/${a.id}`)}
|
||||||
>
|
>
|
||||||
@@ -823,7 +859,7 @@ export default function ArtistDetail() {
|
|||||||
</h2>
|
</h2>
|
||||||
{albums.length > 0 ? (
|
{albums.length > 0 ? (
|
||||||
<div className="album-grid-wrap album-grid-wrap--artist">
|
<div className="album-grid-wrap album-grid-wrap--artist">
|
||||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||||
@@ -844,7 +880,7 @@ export default function ArtistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
|
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
{featuredAlbums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
+197
-47
@@ -7,10 +7,23 @@ import { usePlayerStore } from '../store/playerStore';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||||
|
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
|
||||||
const ALL_SENTINEL = 'ALL';
|
const ALL_SENTINEL = 'ALL';
|
||||||
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||||
|
|
||||||
|
/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */
|
||||||
|
const ARTIST_LIST_LETTER_ROW_EST = 48;
|
||||||
|
const ARTIST_LIST_ROW_EST = 64;
|
||||||
|
const ARTIST_LIST_LAST_IN_LETTER_EST = 88;
|
||||||
|
|
||||||
|
type ArtistListFlatRow =
|
||||||
|
| { kind: 'letter'; letter: string }
|
||||||
|
| { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean };
|
||||||
|
|
||||||
// Catppuccin accent colors — one is picked deterministically from the artist name
|
// Catppuccin accent colors — one is picked deterministically from the artist name
|
||||||
const CTP_COLORS = [
|
const CTP_COLORS = [
|
||||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||||
@@ -35,12 +48,20 @@ function nameInitial(name: string): string {
|
|||||||
|
|
||||||
function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||||
const color = nameColor(artist.name);
|
const color = nameColor(artist.name);
|
||||||
if (showImages && artist.coverArt) {
|
const coverId = artist.coverArt || artist.id;
|
||||||
|
const { coverSrc, coverKey } = useMemo(
|
||||||
|
() => ({
|
||||||
|
coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '',
|
||||||
|
coverKey: coverId ? coverArtCacheKey(coverId, 300) : '',
|
||||||
|
}),
|
||||||
|
[coverId],
|
||||||
|
);
|
||||||
|
if (showImages && coverId) {
|
||||||
return (
|
return (
|
||||||
<div className="artist-card-avatar">
|
<div className="artist-card-avatar">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
src={buildCoverArtUrl(artist.coverArt, 300)}
|
src={coverSrc}
|
||||||
cacheKey={coverArtCacheKey(artist.coverArt, 300)}
|
cacheKey={coverKey}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,12 +76,20 @@ function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; show
|
|||||||
|
|
||||||
function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||||
const color = nameColor(artist.name);
|
const color = nameColor(artist.name);
|
||||||
if (showImages && artist.coverArt) {
|
const coverId = artist.coverArt || artist.id;
|
||||||
|
const { coverSrc, coverKey } = useMemo(
|
||||||
|
() => ({
|
||||||
|
coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '',
|
||||||
|
coverKey: coverId ? coverArtCacheKey(coverId, 64) : '',
|
||||||
|
}),
|
||||||
|
[coverId],
|
||||||
|
);
|
||||||
|
if (showImages && coverId) {
|
||||||
return (
|
return (
|
||||||
<div className="artist-avatar">
|
<div className="artist-avatar">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
src={buildCoverArtUrl(artist.coverArt, 64)}
|
src={coverSrc}
|
||||||
cacheKey={coverArtCacheKey(artist.coverArt, 64)}
|
cacheKey={coverKey}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||||
/>
|
/>
|
||||||
@@ -75,6 +104,7 @@ function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showI
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Artists() {
|
export default function Artists() {
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -184,6 +214,46 @@ export default function Artists() {
|
|||||||
return { groups: g, letters: Object.keys(g).sort() };
|
return { groups: g, letters: Object.keys(g).sort() };
|
||||||
}, [visible, viewMode]);
|
}, [visible, viewMode]);
|
||||||
|
|
||||||
|
const artistListFlatRows = useMemo((): ArtistListFlatRow[] => {
|
||||||
|
if (viewMode !== 'list') return [];
|
||||||
|
const out: ArtistListFlatRow[] = [];
|
||||||
|
for (const letter of letters) {
|
||||||
|
out.push({ kind: 'letter', letter });
|
||||||
|
const group = groups[letter];
|
||||||
|
for (let i = 0; i < group.length; i++) {
|
||||||
|
out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [viewMode, letters, groups]);
|
||||||
|
|
||||||
|
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||||
|
/** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */
|
||||||
|
const artistListOverscan = Math.max(
|
||||||
|
12,
|
||||||
|
Math.ceil(mainScrollViewportHeight / ARTIST_LIST_ROW_EST),
|
||||||
|
);
|
||||||
|
|
||||||
|
const artistListVirtualizer = useVirtualizer({
|
||||||
|
count:
|
||||||
|
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length,
|
||||||
|
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
|
||||||
|
estimateSize: index => {
|
||||||
|
const row = artistListFlatRows[index];
|
||||||
|
if (!row) return ARTIST_LIST_ROW_EST;
|
||||||
|
if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST;
|
||||||
|
return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST;
|
||||||
|
},
|
||||||
|
/** Stable keys — avoids row DOM reuse glitches when the filtered slice changes. */
|
||||||
|
getItemKey: index => {
|
||||||
|
const row = artistListFlatRows[index];
|
||||||
|
if (!row) return index;
|
||||||
|
if (row.kind === 'letter') return `letter:${row.letter}`;
|
||||||
|
return `artist:${row.artist.id}`;
|
||||||
|
},
|
||||||
|
overscan: artistListOverscan,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<div className="page-sticky-header">
|
<div className="page-sticky-header">
|
||||||
@@ -307,49 +377,129 @@ export default function Artists() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && viewMode === 'list' && (
|
{!loading && viewMode === 'list' && (
|
||||||
<>
|
perfFlags.disableMainstageVirtualLists ? (
|
||||||
{letters.map(letter => (
|
<>
|
||||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
{letters.map(letter => (
|
||||||
<h3 className="letter-heading">{letter}</h3>
|
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||||
<div className="artist-list">
|
<h3 className="letter-heading">{letter}</h3>
|
||||||
{groups[letter].map(artist => (
|
<div className="artist-list">
|
||||||
<button
|
{groups[letter].map(artist => (
|
||||||
key={artist.id}
|
<button
|
||||||
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
key={artist.id}
|
||||||
onClick={() => {
|
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
||||||
if (selectionMode) {
|
onClick={() => {
|
||||||
toggleSelect(artist.id);
|
if (selectionMode) {
|
||||||
} else {
|
toggleSelect(artist.id);
|
||||||
navigate(`/artist/${artist.id}`);
|
} else {
|
||||||
}
|
navigate(`/artist/${artist.id}`);
|
||||||
}}
|
}
|
||||||
onContextMenu={(e) => {
|
}}
|
||||||
e.preventDefault();
|
onContextMenu={(e) => {
|
||||||
if (selectionMode && selectedIds.size > 0) {
|
e.preventDefault();
|
||||||
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
if (selectionMode && selectedIds.size > 0) {
|
||||||
} else {
|
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
} else {
|
||||||
}
|
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||||
}}
|
}
|
||||||
id={`artist-${artist.id}`}
|
}}
|
||||||
style={selectionMode && selectedIds.has(artist.id) ? {
|
id={`artist-${artist.id}`}
|
||||||
background: 'var(--accent-dim)',
|
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||||
color: 'var(--accent)'
|
background: 'var(--accent-dim)',
|
||||||
} : {}}
|
color: 'var(--accent)'
|
||||||
>
|
} : {}}
|
||||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
>
|
||||||
<div style={{ textAlign: 'left' }}>
|
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||||
<div className="artist-name">{artist.name}</div>
|
<div style={{ textAlign: 'left' }}>
|
||||||
{artist.albumCount != null && (
|
<div className="artist-name">{artist.name}</div>
|
||||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
{artist.albumCount != null && (
|
||||||
)}
|
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||||
</div>
|
)}
|
||||||
</button>
|
</div>
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ position: 'relative', width: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: artistListFlatRows.length === 0 ? 0 : artistListVirtualizer.getTotalSize(),
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artistListVirtualizer.getVirtualItems().map(vi => {
|
||||||
|
const row = artistListFlatRows[vi.index];
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.kind === 'letter') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vi.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="letter-heading">{row.letter}</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const artist = row.artist;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vi.start}px)`,
|
||||||
|
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectionMode) {
|
||||||
|
toggleSelect(artist.id);
|
||||||
|
} else {
|
||||||
|
navigate(`/artist/${artist.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectionMode && selectedIds.size > 0) {
|
||||||
|
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||||
|
} else {
|
||||||
|
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
id={`artist-${artist.id}`}
|
||||||
|
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||||
|
background: 'var(--accent-dim)',
|
||||||
|
color: 'var(--accent)'
|
||||||
|
} : {}}
|
||||||
|
>
|
||||||
|
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||||
|
<div style={{ textAlign: 'left' }}>
|
||||||
|
<div className="artist-name">{artist.name}</div>
|
||||||
|
{artist.albumCount != null && (
|
||||||
|
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</>
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && hasMore && (
|
{!loading && hasMore && (
|
||||||
|
|||||||
+13
-10
@@ -11,6 +11,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
||||||
|
import { dedupeById } from '../utils/dedupeById';
|
||||||
|
|
||||||
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||||
const HOME_RANDOM_FETCH = 100;
|
const HOME_RANDOM_FETCH = 100;
|
||||||
@@ -20,8 +21,9 @@ const HOME_DISCOVER_SONGS_SIZE = 18;
|
|||||||
const HOME_ALBUM_ROW_ARTWORK_SIZE = 300;
|
const HOME_ALBUM_ROW_ARTWORK_SIZE = 300;
|
||||||
const HOME_SONG_RAIL_ARTWORK_SIZE = 200;
|
const HOME_SONG_RAIL_ARTWORK_SIZE = 200;
|
||||||
const HOME_ARTWORK_WINDOWING = true;
|
const HOME_ARTWORK_WINDOWING = true;
|
||||||
const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 3;
|
// At least one viewport width of cards on first paint (low values left half the row as placeholders).
|
||||||
const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 4;
|
const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 14;
|
||||||
|
const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 16;
|
||||||
// Keep artwork enabled across Home rows in normal mode.
|
// Keep artwork enabled across Home rows in normal mode.
|
||||||
const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
|
const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
|
||||||
|
|
||||||
@@ -73,20 +75,20 @@ export default function Home() {
|
|||||||
: Promise.resolve<SubsonicSong[]>([]),
|
: Promise.resolve<SubsonicSong[]>([]),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
|
const r = dedupeById(await filterAlbumsByMixRatings(rRaw, mixCfg));
|
||||||
setStarred(s);
|
setStarred(dedupeById(s));
|
||||||
setRecent(n);
|
setRecent(dedupeById(n));
|
||||||
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
||||||
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
||||||
setMostPlayed(f);
|
setMostPlayed(dedupeById(f));
|
||||||
setRecentlyPlayed(rp);
|
setRecentlyPlayed(dedupeById(rp));
|
||||||
setDiscoverSongs(songs);
|
setDiscoverSongs(dedupeById(songs));
|
||||||
const shuffled = [...artists];
|
const shuffled = [...artists];
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
}
|
}
|
||||||
setRandomArtists(shuffled.slice(0, 16));
|
setRandomArtists(dedupeById(shuffled).slice(0, 16));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -111,8 +113,9 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
const more = await getAlbumList(type, 12, currentList.length);
|
const more = await getAlbumList(type, 12, currentList.length);
|
||||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||||
const batch =
|
const batchRaw =
|
||||||
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
|
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
|
||||||
|
const batch = dedupeById(batchRaw);
|
||||||
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
|
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
|
||||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound } from 'lucide-react';
|
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound } from 'lucide-react';
|
||||||
import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
@@ -49,6 +49,12 @@ function formatPlays(n: number, t: ReturnType<typeof import('react-i18next').use
|
|||||||
return t('mostPlayed.plays', { n: n.toLocaleString() }) as string;
|
return t('mostPlayed.plays', { n: n.toLocaleString() }) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MpCover80({ coverArt, alt, className }: { coverArt: string; alt: string; className: string }) {
|
||||||
|
const src = useMemo(() => buildCoverArtUrl(coverArt, 80), [coverArt]);
|
||||||
|
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 80), [coverArt]);
|
||||||
|
return <CachedImage src={src} cacheKey={cacheKey} alt={alt} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MostPlayed() {
|
export default function MostPlayed() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -136,12 +142,7 @@ export default function MostPlayed() {
|
|||||||
>
|
>
|
||||||
<span className="mp-rank">{i + 1}</span>
|
<span className="mp-rank">{i + 1}</span>
|
||||||
{artist.coverArt ? (
|
{artist.coverArt ? (
|
||||||
<CachedImage
|
<MpCover80 coverArt={artist.coverArt} alt="" className="mp-artist-avatar" />
|
||||||
src={buildCoverArtUrl(artist.coverArt, 80)}
|
|
||||||
cacheKey={coverArtCacheKey(artist.coverArt, 80)}
|
|
||||||
alt=""
|
|
||||||
className="mp-artist-avatar"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mp-artist-avatar mp-artist-avatar--placeholder" />
|
<div className="mp-artist-avatar mp-artist-avatar--placeholder" />
|
||||||
)}
|
)}
|
||||||
@@ -175,12 +176,7 @@ export default function MostPlayed() {
|
|||||||
>
|
>
|
||||||
<span className="mp-album-rank">{sortAsc ? withPlays.length - i : i + 1}</span>
|
<span className="mp-album-rank">{sortAsc ? withPlays.length - i : i + 1}</span>
|
||||||
{album.coverArt ? (
|
{album.coverArt ? (
|
||||||
<CachedImage
|
<MpCover80 coverArt={album.coverArt} alt="" className="mp-album-cover" />
|
||||||
src={buildCoverArtUrl(album.coverArt, 80)}
|
|
||||||
cacheKey={coverArtCacheKey(album.coverArt, 80)}
|
|
||||||
alt=""
|
|
||||||
className="mp-album-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mp-album-cover mp-album-cover--placeholder" />
|
<div className="mp-album-cover mp-album-cover--placeholder" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ const PL_COLUMNS: readonly ColDef[] = [
|
|||||||
|
|
||||||
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
|
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
|
||||||
|
|
||||||
|
function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) {
|
||||||
|
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
|
||||||
|
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]);
|
||||||
|
return <CachedImage src={src} cacheKey={cacheKey} alt="" className="playlist-search-thumb" />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PlaylistDetail() {
|
export default function PlaylistDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -1408,7 +1414,7 @@ export default function PlaylistDetail() {
|
|||||||
return next;
|
return next;
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
<PlaylistSearchResultThumb coverArt={song.coverArt ?? ''} />
|
||||||
<div className="playlist-search-info">
|
<div className="playlist-search-info">
|
||||||
<span className="playlist-search-title">{song.title}</span>
|
<span className="playlist-search-title">{song.title}</span>
|
||||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||||
|
|||||||
+29
-14
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react';
|
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react';
|
||||||
import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre, filterSongsToActiveLibrary } from '../api/subsonic';
|
import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre, filterSongsToActiveLibrary } from '../api/subsonic';
|
||||||
@@ -16,6 +16,32 @@ function formatDuration(seconds: number): string {
|
|||||||
return formatHumanHoursMinutes(seconds);
|
return formatHumanHoursMinutes(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
|
||||||
|
const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]);
|
||||||
|
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]);
|
||||||
|
return (
|
||||||
|
<CachedImage
|
||||||
|
className="playlist-cover-cell"
|
||||||
|
src={src}
|
||||||
|
cacheKey={cacheKey}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
|
||||||
|
const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]);
|
||||||
|
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]);
|
||||||
|
return (
|
||||||
|
<CachedImage
|
||||||
|
src={src}
|
||||||
|
cacheKey={cacheKey}
|
||||||
|
alt={alt}
|
||||||
|
className="album-card-cover-img"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SMART_PREFIX = 'psy-smart-';
|
const SMART_PREFIX = 'psy-smart-';
|
||||||
const LIMIT_MAX = 500;
|
const LIMIT_MAX = 500;
|
||||||
const YEAR_MIN = 1950;
|
const YEAR_MIN = 1950;
|
||||||
@@ -940,25 +966,14 @@ export default function Playlists() {
|
|||||||
{Array.from({ length: 4 }, (_, i) => {
|
{Array.from({ length: 4 }, (_, i) => {
|
||||||
const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length];
|
const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length];
|
||||||
return id ? (
|
return id ? (
|
||||||
<CachedImage
|
<PlaylistSmartCoverCell key={i} coverId={id} />
|
||||||
key={i}
|
|
||||||
className="playlist-cover-cell"
|
|
||||||
src={buildCoverArtUrl(id, 200)}
|
|
||||||
cacheKey={coverArtCacheKey(id, 200)}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : pl.coverArt ? (
|
) : pl.coverArt ? (
|
||||||
<CachedImage
|
<PlaylistCardMainCover coverArt={pl.coverArt} alt={pl.name} />
|
||||||
src={buildCoverArtUrl(pl.coverArt, 256)}
|
|
||||||
cacheKey={coverArtCacheKey(pl.coverArt, 256)}
|
|
||||||
alt={pl.name}
|
|
||||||
className="album-card-cover-img"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="album-card-cover-placeholder playlist-card-icon">
|
<div className="album-card-cover-placeholder playlist-card-icon">
|
||||||
<ListMusic size={48} strokeWidth={1.2} />
|
<ListMusic size={48} strokeWidth={1.2} />
|
||||||
|
|||||||
+10
-3
@@ -28,7 +28,7 @@ const RATED_RAIL_DISPLAY = 30;
|
|||||||
const RATED_RAIL_CACHE_MS = 60_000;
|
const RATED_RAIL_CACHE_MS = 60_000;
|
||||||
/** Match Home: only mount artwork for cards near the horizontal viewport. */
|
/** Match Home: only mount artwork for cards near the horizontal viewport. */
|
||||||
const TRACKS_SONG_RAIL_WINDOWING = true;
|
const TRACKS_SONG_RAIL_WINDOWING = true;
|
||||||
const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 4;
|
const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 14;
|
||||||
|
|
||||||
export default function Tracks() {
|
export default function Tracks() {
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
@@ -91,7 +91,14 @@ export default function Tracks() {
|
|||||||
reloadRated();
|
reloadRated();
|
||||||
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
|
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
|
||||||
|
|
||||||
const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : '';
|
const heroCoverUrl = useMemo(
|
||||||
|
() => (hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : ''),
|
||||||
|
[hero?.coverArt],
|
||||||
|
);
|
||||||
|
const heroCoverKey = useMemo(
|
||||||
|
() => (hero?.coverArt ? coverArtCacheKey(hero.coverArt, 600) : ''),
|
||||||
|
[hero?.coverArt],
|
||||||
|
);
|
||||||
|
|
||||||
// Hide the hero song from the random rail if the server happens to return it in
|
// Hide the hero song from the random rail if the server happens to return it in
|
||||||
// both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window).
|
// both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window).
|
||||||
@@ -117,7 +124,7 @@ export default function Tracks() {
|
|||||||
{heroCoverUrl ? (
|
{heroCoverUrl ? (
|
||||||
<CachedImage
|
<CachedImage
|
||||||
src={heroCoverUrl}
|
src={heroCoverUrl}
|
||||||
cacheKey={coverArtCacheKey(hero.coverArt!, 600)}
|
cacheKey={heroCoverKey}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Keeps the first occurrence of each `id`. Subsonic responses (and merged pages)
|
||||||
|
* occasionally repeat the same album/song id; duplicate React keys then warn and
|
||||||
|
* break reconciliation.
|
||||||
|
*/
|
||||||
|
export function dedupeById<T extends { id: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue;
|
||||||
|
seen.add(item.id);
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+93
-33
@@ -117,49 +117,109 @@ export function ensureContrast(
|
|||||||
const FS_BG_LUMINANCE = 0.010;
|
const FS_BG_LUMINANCE = 0.010;
|
||||||
const MIN_CONTRAST = 4.5;
|
const MIN_CONTRAST = 4.5;
|
||||||
|
|
||||||
|
function isRemoteHttpUrl(url: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlobOrDataUrl(url: string): boolean {
|
||||||
|
return url.startsWith('blob:') || url.startsWith('data:');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples decoded pixels from an HTMLImageElement (already loaded).
|
||||||
|
* Throws if the canvas is tainted (caller should catch).
|
||||||
|
*/
|
||||||
|
function sampleImageToAccent(img: HTMLImageElement): CoverColors {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 8;
|
||||||
|
canvas.height = 8;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return { accent: '' };
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, 8, 8);
|
||||||
|
const { data } = ctx.getImageData(0, 0, 8, 8);
|
||||||
|
|
||||||
|
let bestSat = -1;
|
||||||
|
let bestR = 180;
|
||||||
|
let bestG = 100;
|
||||||
|
let bestB = 50;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i];
|
||||||
|
const g = data[i + 1];
|
||||||
|
const b = data[i + 2];
|
||||||
|
const [, s] = rgbToHsl(r, g, b);
|
||||||
|
if (s > bestSat) {
|
||||||
|
bestSat = s;
|
||||||
|
bestR = r;
|
||||||
|
bestG = g;
|
||||||
|
bestB = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fr, fg, fb] = ensureContrast([bestR, bestG, bestB], FS_BG_LUMINANCE, MIN_CONTRAST);
|
||||||
|
return { accent: `rgb(${fr},${fg},${fb})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(url: string, crossOrigin: '' | 'anonymous'): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('image load failed'));
|
||||||
|
if (crossOrigin) img.crossOrigin = crossOrigin;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads `imageUrl` into an 8×8 canvas and finds the most vibrant pixel
|
* Loads `imageUrl` into an 8×8 canvas and finds the most vibrant pixel
|
||||||
* (highest HSL saturation). Applies `ensureContrast` to guarantee
|
* (highest HSL saturation). Applies `ensureContrast` to guarantee
|
||||||
* WCAG AA readability against the FS player background.
|
* WCAG AA readability against the FS player background.
|
||||||
*
|
*
|
||||||
|
* Remote `https?://` URLs would taint a canvas if drawn from a plain `<img>`
|
||||||
|
* without CORS — we prefer `fetch` → `blob:` for sampling, then fall back to
|
||||||
|
* `crossOrigin = "anonymous"` when the server allows it.
|
||||||
|
*
|
||||||
* Resolves with `{ accent: '' }` on any error — the caller's CSS
|
* Resolves with `{ accent: '' }` on any error — the caller's CSS
|
||||||
* `var(--dynamic-fs-accent, var(--accent))` then falls back to the theme accent.
|
* `var(--dynamic-fs-accent, var(--accent))` then falls back to the theme accent.
|
||||||
*/
|
*/
|
||||||
export function extractCoverColors(imageUrl: string): Promise<CoverColors> {
|
export async function extractCoverColors(imageUrl: string): Promise<CoverColors> {
|
||||||
if (!imageUrl) return Promise.resolve({ accent: '' });
|
if (!imageUrl) return { accent: '' };
|
||||||
// Logo fallback has no meaningful color — skip extraction and use theme accent
|
if (imageUrl.includes('logo-psysonic')) return { accent: '' };
|
||||||
if (imageUrl.includes('logo-psysonic')) return Promise.resolve({ accent: '' });
|
|
||||||
|
|
||||||
return new Promise(resolve => {
|
const safeSample = async (url: string, co: '' | 'anonymous'): Promise<CoverColors> => {
|
||||||
const img = new Image();
|
try {
|
||||||
// Blob URLs are same-origin in Tauri WebKit — no crossOrigin needed.
|
const img = await loadImage(url, co);
|
||||||
img.onload = () => {
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
return sampleImageToAccent(img);
|
||||||
canvas.width = 8;
|
|
||||||
canvas.height = 8;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) { resolve({ accent: '' }); return; }
|
|
||||||
|
|
||||||
ctx.drawImage(img, 0, 0, 8, 8);
|
|
||||||
const { data } = ctx.getImageData(0, 0, 8, 8);
|
|
||||||
|
|
||||||
// Pick pixel with highest HSL saturation (most vibrant).
|
|
||||||
let bestSat = -1;
|
|
||||||
let bestR = 180, bestG = 100, bestB = 50; // warm orange fallback
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
|
||||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
|
||||||
const [, s] = rgbToHsl(r, g, b);
|
|
||||||
if (s > bestSat) { bestSat = s; bestR = r; bestG = g; bestB = b; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [fr, fg, fb] = ensureContrast([bestR, bestG, bestB], FS_BG_LUMINANCE, MIN_CONTRAST);
|
|
||||||
resolve({ accent: `rgb(${fr},${fg},${fb})` });
|
|
||||||
} catch {
|
} catch {
|
||||||
resolve({ accent: '' });
|
return { accent: '' };
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
img.onerror = () => resolve({ accent: '' });
|
return { accent: '' };
|
||||||
img.src = imageUrl;
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (isBlobOrDataUrl(imageUrl)) {
|
||||||
|
return safeSample(imageUrl, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRemoteHttpUrl(imageUrl)) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(imageUrl);
|
||||||
|
if (resp.ok) {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
return await safeSample(objectUrl, '');
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// CORS / network — try credentialed image load if server sends ACAO for art
|
||||||
|
}
|
||||||
|
return safeSample(imageUrl, 'anonymous');
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeSample(imageUrl, '');
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-54
@@ -3,8 +3,19 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
const DB_NAME = 'psysonic-img-cache';
|
const DB_NAME = 'psysonic-img-cache';
|
||||||
const STORE_NAME = 'images';
|
const STORE_NAME = 'images';
|
||||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const MAX_BLOB_CACHE = 200; // hot in-memory blob entries (LRU)
|
/** In-memory blobs — scrolling large grids used to thrash at 200 and re-hit IndexedDB for “cold” keys that still had a live shared object URL. */
|
||||||
const MAX_CONCURRENT_FETCHES = 5;
|
const MAX_BLOB_CACHE = 600; // hot in-memory blob entries (LRU)
|
||||||
|
/** Network-only pool — IndexedDB hits must not queue behind remote fetches. */
|
||||||
|
const MAX_CONCURRENT_NET_FETCHES = 6;
|
||||||
|
|
||||||
|
type LoadWaiter = {
|
||||||
|
getPriority: () => number;
|
||||||
|
resolve: (granted: boolean) => void;
|
||||||
|
};
|
||||||
|
const loadWaiters: LoadWaiter[] = [];
|
||||||
|
|
||||||
|
/** One in-flight read per logical image — avoids duplicate IndexedDB transactions when many cells mount together. */
|
||||||
|
const inflightBlobGets = new Map<string, Promise<Blob | null>>();
|
||||||
|
|
||||||
// In-memory blob cache: cacheKey → Blob (insertion-order = LRU approximation).
|
// In-memory blob cache: cacheKey → Blob (insertion-order = LRU approximation).
|
||||||
// Only the Map entry is dropped on overflow — the underlying Blob is freed by
|
// Only the Map entry is dropped on overflow — the underlying Blob is freed by
|
||||||
@@ -32,21 +43,34 @@ function purgeUrlEntry(cacheKey: string): void {
|
|||||||
* Returns a shared object URL for the cached blob of `cacheKey`, or null if
|
* Returns a shared object URL for the cached blob of `cacheKey`, or null if
|
||||||
* not currently in memory. Pair every successful call with releaseUrl().
|
* not currently in memory. Pair every successful call with releaseUrl().
|
||||||
* Subsequent acquires reuse the same URL and just bump the refcount.
|
* Subsequent acquires reuse the same URL and just bump the refcount.
|
||||||
|
*
|
||||||
|
* IMPORTANT: the Blob can be LRU-evicted from `blobCache` while `urlEntries`
|
||||||
|
* still holds a valid object URL (another `<img>` still references it). We
|
||||||
|
* must reuse that URL — otherwise callers fall through to IndexedDB / network
|
||||||
|
* again and scrolling janks even when data was already resolved once.
|
||||||
*/
|
*/
|
||||||
export function acquireUrl(cacheKey: string): string | null {
|
export function acquireUrl(cacheKey: string): string | null {
|
||||||
const blob = blobCache.get(cacheKey);
|
const blob = blobCache.get(cacheKey);
|
||||||
if (!blob) return null;
|
if (blob) {
|
||||||
rememberBlob(cacheKey, blob); // refresh LRU position
|
rememberBlob(cacheKey, blob); // refresh LRU position
|
||||||
let entry = urlEntries.get(cacheKey);
|
|
||||||
if (!entry) {
|
|
||||||
entry = { url: URL.createObjectURL(blob), refs: 0, revokeTimer: null };
|
|
||||||
urlEntries.set(cacheKey, entry);
|
|
||||||
} else if (entry.revokeTimer) {
|
|
||||||
clearTimeout(entry.revokeTimer);
|
|
||||||
entry.revokeTimer = null;
|
|
||||||
}
|
}
|
||||||
entry.refs++;
|
|
||||||
return entry.url;
|
const entry = urlEntries.get(cacheKey);
|
||||||
|
if (entry) {
|
||||||
|
if (entry.revokeTimer) {
|
||||||
|
clearTimeout(entry.revokeTimer);
|
||||||
|
entry.revokeTimer = null;
|
||||||
|
}
|
||||||
|
entry.refs++;
|
||||||
|
return entry.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blob) return null;
|
||||||
|
|
||||||
|
const newEntry: UrlEntry = { url: URL.createObjectURL(blob), refs: 0, revokeTimer: null };
|
||||||
|
urlEntries.set(cacheKey, newEntry);
|
||||||
|
newEntry.refs++;
|
||||||
|
return newEntry.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrements the refcount; revokes (after grace delay) when it reaches zero. */
|
/** Decrements the refcount; revokes (after grace delay) when it reaches zero. */
|
||||||
@@ -61,34 +85,72 @@ export function releaseUrl(cacheKey: string): void {
|
|||||||
}, URL_REVOKE_DELAY_MS);
|
}, URL_REVOKE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeFetches = 0;
|
let activeNetFetches = 0;
|
||||||
const fetchQueue: Array<() => void> = [];
|
|
||||||
|
|
||||||
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
|
function removeLoadWaiter(waiter: LoadWaiter): void {
|
||||||
|
const i = loadWaiters.indexOf(waiter);
|
||||||
|
if (i !== -1) loadWaiters.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slot for remote `fetch` only. IndexedDB reads run before this — cached disk
|
||||||
|
* art can render without waiting on in-flight network downloads.
|
||||||
|
*/
|
||||||
|
function acquireNetFetchSlot(signal?: AbortSignal, getPriority?: () => number): Promise<boolean> {
|
||||||
if (signal?.aborted) return Promise.resolve(false);
|
if (signal?.aborted) return Promise.resolve(false);
|
||||||
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
if (activeNetFetches < MAX_CONCURRENT_NET_FETCHES) {
|
||||||
activeFetches++;
|
activeNetFetches++;
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
return new Promise<boolean>(resolve => {
|
return new Promise<boolean>(resolve => {
|
||||||
const onGrant = () => {
|
let waiter: LoadWaiter;
|
||||||
signal?.removeEventListener('abort', onAbort);
|
|
||||||
resolve(true);
|
|
||||||
};
|
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
const idx = fetchQueue.indexOf(onGrant);
|
signal?.removeEventListener('abort', onAbort);
|
||||||
if (idx !== -1) fetchQueue.splice(idx, 1);
|
removeLoadWaiter(waiter);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
};
|
};
|
||||||
fetchQueue.push(onGrant);
|
waiter = {
|
||||||
|
getPriority: getPriority ?? (() => 0),
|
||||||
|
resolve: (granted: boolean) => {
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
resolve(granted);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
loadWaiters.push(waiter);
|
||||||
signal?.addEventListener('abort', onAbort, { once: true });
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseFetchSlot(): void {
|
function pickHighestPriorityWaiterIndex(): number {
|
||||||
activeFetches--;
|
if (loadWaiters.length === 0) return -1;
|
||||||
const next = fetchQueue.shift();
|
let best = 0;
|
||||||
if (next) { activeFetches++; next(); }
|
let bestP = safePriority(loadWaiters[0].getPriority);
|
||||||
|
for (let i = 1; i < loadWaiters.length; i++) {
|
||||||
|
const p = safePriority(loadWaiters[i].getPriority);
|
||||||
|
if (p > bestP) {
|
||||||
|
bestP = p;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePriority(fn: () => number): number {
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseNetFetchSlot(): void {
|
||||||
|
activeNetFetches = Math.max(0, activeNetFetches - 1);
|
||||||
|
if (activeNetFetches >= MAX_CONCURRENT_NET_FETCHES) return;
|
||||||
|
const idx = pickHighestPriorityWaiterIndex();
|
||||||
|
if (idx === -1) return;
|
||||||
|
const [w] = loadWaiters.splice(idx, 1);
|
||||||
|
activeNetFetches++;
|
||||||
|
w.resolve(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberBlob(key: string, blob: Blob): void {
|
function rememberBlob(key: string, blob: Blob): void {
|
||||||
@@ -175,6 +237,19 @@ async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Batched eviction — avoids `getAll()` on every cover write during fast scrolling. */
|
||||||
|
let evictDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let evictPendingMaxBytes = 0;
|
||||||
|
|
||||||
|
function scheduleEvictDiskIfNeeded(maxBytes: number): void {
|
||||||
|
evictPendingMaxBytes = maxBytes;
|
||||||
|
if (evictDebounceTimer) clearTimeout(evictDebounceTimer);
|
||||||
|
evictDebounceTimer = setTimeout(() => {
|
||||||
|
evictDebounceTimer = null;
|
||||||
|
void evictDiskIfNeeded(evictPendingMaxBytes);
|
||||||
|
}, 450);
|
||||||
|
}
|
||||||
|
|
||||||
async function putBlob(key: string, blob: Blob): Promise<void> {
|
async function putBlob(key: string, blob: Blob): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
@@ -185,7 +260,7 @@ async function putBlob(key: string, blob: Blob): Promise<void> {
|
|||||||
tx.onerror = () => resolve();
|
tx.onerror = () => resolve();
|
||||||
});
|
});
|
||||||
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
|
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
|
||||||
evictDiskIfNeeded(maxBytes);
|
scheduleEvictDiskIfNeeded(maxBytes);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore write errors
|
// Ignore write errors
|
||||||
}
|
}
|
||||||
@@ -210,6 +285,7 @@ export async function getImageCacheSize(): Promise<number> {
|
|||||||
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
||||||
blobCache.delete(cacheKey);
|
blobCache.delete(cacheKey);
|
||||||
purgeUrlEntry(cacheKey);
|
purgeUrlEntry(cacheKey);
|
||||||
|
inflightBlobGets.delete(cacheKey);
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
@@ -230,7 +306,12 @@ export async function invalidateCoverArt(entityId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function clearImageCache(): Promise<void> {
|
export async function clearImageCache(): Promise<void> {
|
||||||
|
if (evictDebounceTimer) {
|
||||||
|
clearTimeout(evictDebounceTimer);
|
||||||
|
evictDebounceTimer = null;
|
||||||
|
}
|
||||||
blobCache.clear();
|
blobCache.clear();
|
||||||
|
inflightBlobGets.clear();
|
||||||
for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key);
|
for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key);
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
@@ -253,8 +334,14 @@ export async function clearImageCache(): Promise<void> {
|
|||||||
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
||||||
* @param cacheKey A stable key that identifies the image across sessions.
|
* @param cacheKey A stable key that identifies the image across sessions.
|
||||||
* @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches.
|
* @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches.
|
||||||
|
* @param getPriority Called when waiting for a **network** slot (IndexedDB hits skip this queue).
|
||||||
*/
|
*/
|
||||||
export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<Blob | null> {
|
export async function getCachedBlob(
|
||||||
|
fetchUrl: string,
|
||||||
|
cacheKey: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
getPriority?: () => number,
|
||||||
|
): Promise<Blob | null> {
|
||||||
if (!fetchUrl || signal?.aborted) return null;
|
if (!fetchUrl || signal?.aborted) return null;
|
||||||
|
|
||||||
const memHit = blobCache.get(cacheKey);
|
const memHit = blobCache.get(cacheKey);
|
||||||
@@ -263,29 +350,40 @@ export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?:
|
|||||||
return memHit;
|
return memHit;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idbHit = await getBlobFromIDB(cacheKey);
|
const existing = inflightBlobGets.get(cacheKey);
|
||||||
if (signal?.aborted) return null;
|
if (existing) return existing;
|
||||||
if (idbHit) {
|
|
||||||
rememberBlob(cacheKey, idbHit);
|
|
||||||
return idbHit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const acquired = await acquireFetchSlot(signal);
|
const run = (async () => {
|
||||||
if (!acquired || signal?.aborted) {
|
|
||||||
if (acquired) releaseFetchSlot();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resp = await fetch(fetchUrl, { signal });
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
const newBlob = await resp.blob();
|
|
||||||
if (signal?.aborted) return null;
|
if (signal?.aborted) return null;
|
||||||
putBlob(cacheKey, newBlob); // fire-and-forget
|
|
||||||
rememberBlob(cacheKey, newBlob);
|
const idbHit = await getBlobFromIDB(cacheKey);
|
||||||
return newBlob;
|
if (signal?.aborted) return null;
|
||||||
} catch {
|
if (idbHit) {
|
||||||
return null;
|
rememberBlob(cacheKey, idbHit);
|
||||||
} finally {
|
return idbHit;
|
||||||
releaseFetchSlot();
|
}
|
||||||
}
|
|
||||||
|
const acquired = await acquireNetFetchSlot(signal, getPriority);
|
||||||
|
if (!acquired || signal?.aborted) {
|
||||||
|
if (acquired) releaseNetFetchSlot();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(fetchUrl, { signal });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const newBlob = await resp.blob();
|
||||||
|
if (signal?.aborted) return null;
|
||||||
|
putBlob(cacheKey, newBlob); // fire-and-forget
|
||||||
|
rememberBlob(cacheKey, newBlob);
|
||||||
|
return newBlob;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
releaseNetFetchSlot();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflightBlobGets.set(cacheKey, run);
|
||||||
|
run.finally(() => inflightBlobGets.delete(cacheKey));
|
||||||
|
return run;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user