diff --git a/CHANGELOG.md b/CHANGELOG.md index a6436513..e6727622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.1] - 2026-03-16 + +### Fixed + +#### Random Albums — Performance & Memory +- **Auto-refresh removed**: The 30-second auto-cycle timer caused 10 React state updates/second (progress bar interval) and a burst of 30 concurrent image fetches on every tick, eventually making the whole app unresponsive. The page now loads once on mount; use the manual refresh button to get a new selection. +- **Concurrent fetch limit**: Image fetches are now capped at 5 simultaneous network requests (was unlimited — 30 at once on every refresh). +- **Object URL memory leak**: The in-memory image cache now caps at 150 entries and revokes old object URLs via `URL.revokeObjectURL()` when evicting. Previously, object URLs accumulated without bound across the entire session. +- **Dangling state updates**: `useCachedUrl` now uses a cancellation flag — if a component unmounts while a fetch is in flight (e.g. during a grid refresh), the resolved URL is discarded instead of calling `setState` on an unmounted component. + +#### i18n +- Page title "Neueste" on the New Releases page was hardcoded German. Now uses `t('sidebar.newReleases')`. + ## [1.4.0] - 2026-03-16 ### Added diff --git a/package.json b/package.json index 0d2d5623..bd77edf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.4.0", + "version": "1.4.1", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c10202bf..0802ae0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.4.0" +version = "1.4.1" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index aaee16ed..086416f2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.4.0", + "version": "1.4.1", "identifier": "dev.psysonic.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index 52d90a54..49cbe2bd 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -10,7 +10,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string { const [resolved, setResolved] = useState(''); useEffect(() => { if (!fetchUrl) { setResolved(''); return; } - getCachedUrl(fetchUrl, cacheKey).then(setResolved); + let cancelled = false; + getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); }); + return () => { cancelled = true; }; }, [fetchUrl, cacheKey]); return resolved || fetchUrl; } diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index bc23b6e4..6fc47241 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import AlbumCard from '../components/AlbumCard'; import { getAlbumList, SubsonicAlbum } from '../api/subsonic'; +import { useTranslation } from 'react-i18next'; export default function NewReleases() { + const { t } = useTranslation(); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); @@ -49,7 +51,7 @@ export default function NewReleases() { return (
-

Neueste

+

{t('sidebar.newReleases')}

{loading && albums.length === 0 ? (
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index a60c476a..6c56f114 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -4,23 +4,14 @@ import { getAlbumList, SubsonicAlbum } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import { useTranslation } from 'react-i18next'; -const INTERVAL_MS = 30000; const ALBUM_COUNT = 30; export default function RandomAlbums() { const { t } = useTranslation(); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); - const [progress, setProgress] = useState(0); - const timerRef = useRef | null>(null); - const progressRef = useRef | null>(null); const loadingRef = useRef(false); - const clearTimers = () => { - if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } - if (progressRef.current) { clearInterval(progressRef.current); progressRef.current = null; } - }; - const load = useCallback(async () => { if (loadingRef.current) return; loadingRef.current = true; @@ -36,27 +27,7 @@ export default function RandomAlbums() { } }, []); - const startCycle = useCallback(() => { - clearTimers(); - setProgress(0); - const startTime = Date.now(); - progressRef.current = setInterval(() => { - setProgress(Math.min((Date.now() - startTime) / INTERVAL_MS * 100, 100)); - }, 100); - timerRef.current = setInterval(() => { - load().then(() => startCycle()); - }, INTERVAL_MS); - }, [load]); - - useEffect(() => { - load().then(() => startCycle()); - return clearTimers; - }, [load, startCycle]); - - const handleManualRefresh = () => { - clearTimers(); - load().then(() => startCycle()); - }; + useEffect(() => { load(); }, [load]); return (
@@ -64,7 +35,7 @@ export default function RandomAlbums() {

{t('randomAlbums.title')}

- {/* Countdown progress bar */} -
-
-
- {loading && albums.length === 0 ? (
diff --git a/src/styles/components.css b/src/styles/components.css index abb04b4f..d8e88b8f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -313,19 +313,7 @@ gap: var(--space-4); } -.random-albums-progress { - height: 2px; - background: var(--border-subtle); - border-radius: var(--radius-full); - margin-bottom: 1.5rem; - overflow: hidden; -} -.random-albums-progress-fill { - height: 100%; - background: var(--accent); - border-radius: var(--radius-full); - transition: width 0.1s linear; -} + @media (min-width: 1024px) { .album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } } diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts index 451ec002..193176e3 100644 --- a/src/utils/imageCache.ts +++ b/src/utils/imageCache.ts @@ -1,10 +1,39 @@ const DB_NAME = 'psysonic-img-cache'; const STORE_NAME = 'images'; const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const MAX_MEMORY_CACHE = 150; // max object URLs kept in RAM +const MAX_CONCURRENT_FETCHES = 5; -// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session) +// In-memory map: cacheKey → object URL (insertion-order = LRU approximation) const objectUrlCache = new Map(); +// Concurrency limiter for network fetches +let activeFetches = 0; +const fetchQueue: Array<() => void> = []; + +function acquireFetchSlot(): Promise { + if (activeFetches < MAX_CONCURRENT_FETCHES) { + activeFetches++; + return Promise.resolve(); + } + return new Promise(resolve => fetchQueue.push(resolve)); +} + +function releaseFetchSlot(): void { + activeFetches--; + const next = fetchQueue.shift(); + if (next) { activeFetches++; next(); } +} + +function evictIfNeeded(): void { + while (objectUrlCache.size > MAX_MEMORY_CACHE) { + const oldestKey = objectUrlCache.keys().next().value; + if (!oldestKey) break; + URL.revokeObjectURL(objectUrlCache.get(oldestKey)!); + objectUrlCache.delete(oldestKey); + } +} + let db: IDBDatabase | null = null; let dbPromise: Promise | null = null; @@ -75,10 +104,12 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise< if (blob) { const objUrl = URL.createObjectURL(blob); objectUrlCache.set(cacheKey, objUrl); + evictIfNeeded(); return objUrl; } - // 3. Network fetch → store in IDB → return object URL + // 3. Network fetch with concurrency limit → store in IDB → return object URL + await acquireFetchSlot(); try { const resp = await fetch(fetchUrl); if (!resp.ok) return fetchUrl; @@ -86,8 +117,11 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise< putBlob(cacheKey, newBlob); // fire-and-forget const objUrl = URL.createObjectURL(newBlob); objectUrlCache.set(cacheKey, objUrl); + evictIfNeeded(); return objUrl; } catch { - return fetchUrl; // fallback: direct URL + return fetchUrl; + } finally { + releaseFetchSlot(); } }