Merge branch 'main' into feat/waveform-loudness-cache

This commit is contained in:
Psychotoxical
2026-04-26 02:05:10 +02:00
9 changed files with 305 additions and 349 deletions
+27 -4
View File
@@ -1,12 +1,24 @@
import React, { useEffect, useRef, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
import { getCachedBlob } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
cacheKey: string;
}
// Delay between the consumer dropping a cached URL and the actual revoke,
// giving the DOM <img> time to finish its decode of the URL we just took
// away. 500 ms is comfortably above any realistic decode latency without
// being long enough to leak meaningful memory.
const URL_REVOKE_DELAY_MS = 500;
/**
* Returns an object URL for a cached image. Each call owns its own URL: it
* is created when the blob arrives and revoked (after a small grace delay)
* on cleanup. There is no shared URL pool — the previous global LRU caused
* "Failed to load resource" errors when an in-use URL got revoked because a
* different consumer pushed it out of the cache.
*
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
* blob is still resolving — useful for <img> tags so the browser starts
* loading immediately. Pass false for CSS background-image consumers that
@@ -17,11 +29,22 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
useEffect(() => {
if (!fetchUrl) { setResolved(''); return; }
const controller = new AbortController();
let createdUrl: string | null = null;
setResolved('');
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
if (!controller.signal.aborted) setResolved(url);
getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => {
if (controller.signal.aborted) return;
if (blob) {
createdUrl = URL.createObjectURL(blob);
setResolved(createdUrl);
}
});
return () => { controller.abort(); };
return () => {
controller.abort();
if (createdUrl) {
const url = createdUrl;
setTimeout(() => URL.revokeObjectURL(url), URL_REVOKE_DELAY_MS);
}
};
}, [fetchUrl, cacheKey]);
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
}
+2 -2
View File
@@ -7,7 +7,7 @@ import {
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import { getCachedUrl } from '../utils/imageCache';
import { getCachedBlob } from '../utils/imageCache';
import { extractCoverColors } from '../utils/dynamicColors';
import { useTranslation } from 'react-i18next';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
@@ -740,7 +740,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
if (!nextCoverArt) return;
const url = buildCoverArtUrl(nextCoverArt, 300);
const key = coverArtCacheKey(nextCoverArt, 300);
getCachedUrl(url, key).catch(() => {});
getCachedBlob(url, key).catch(() => {});
}, [nextCoverArt]);
// Lyrics settings popover state
+14 -1
View File
@@ -298,6 +298,12 @@ function QueuePanelHostOrSolo() {
const enqueueAt = usePlayerStore(s => s.enqueueAt);
const contextMenu = usePlayerStore(s => s.contextMenu);
// When the user picks a track *from* the queue list, suppress the
// upcoming auto-scroll so their click target stays in view instead of
// the list rebasing onto the next track. Auto-advance (natural playback)
// never sets this flag, so it keeps its original "show what's next" behavior.
const suppressNextAutoScrollRef = useRef(false);
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
@@ -478,6 +484,10 @@ function QueuePanelHostOrSolo() {
}, [enqueueAt]);
useEffect(function queueAutoScroll() {
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
return;
}
if (!queueListRef.current || queueIndex < 0) return;
if (activeTab !== 'queue') return;
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
@@ -916,7 +926,10 @@ function QueuePanelHostOrSolo() {
<div
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
onClick={() => playTrack(track, queue)}
onClick={() => {
suppressNextAutoScrollRef.current = true;
playTrack(track, queue);
}}
onContextMenu={(e) => {
e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);