chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)

* chore(eslint): add eslint toolchain and configs

* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)

* chore(eslint): clear unused vars in config, contexts, app and test

* chore(eslint): clear unused vars in api and music-network

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

* chore(eslint): clear unused vars in cover and hooks

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

* chore(eslint): align react-hooks exhaustive-deps

* chore(eslint): zero gradual config on src

* chore(eslint): strict hook rules in store and utils

* chore(eslint): strict hook rules in hooks and cover

* chore(eslint): strict hook rules in components

* chore(eslint): strict hook rules in pages and contexts

* chore(eslint): document scripts ignore in eslint config

* chore(eslint): add lint script and zero strict findings

* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)

* chore(eslint): tighten two set-state disable comments (review round 2 LOW)

* chore(nix): sync npmDepsHash with package-lock.json

* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Psychotoxical
2026-06-24 01:33:34 +02:00
committed by GitHub
parent c7d76af790
commit 7c724a642f
258 changed files with 2987 additions and 601 deletions
+8
View File
@@ -239,6 +239,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
* Pasted invites are rejected unless they point at a normal http/https server address.
## Under the Hood
### ESLint setup and a strict lint pass over the frontend
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1165](https://github.com/Psychotoxical/psysonic/pull/1165)**
* Added an ESLint config and `npm run lint`, and brought `src/` to zero errors and warnings under the strict React-hooks ruleset. Developer-only — no user-facing behaviour change.
## [1.48.1] - 2026-06-15
## Fixed
+45
View File
@@ -0,0 +1,45 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the browser `src/` tree.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
// This gradual baseline deliberately omits the React Compiler rules (set-state-in-effect, refs,
// immutability, …) that the strict config enables. The per-line `eslint-disable-next-line` directives
// those rules require therefore read as "unused" here, so unused-directive reporting is turned off for
// this config only — the strict config keeps the default reporting and stays 0/0.
{ linterOptions: { reportUnusedDisableDirectives: 'off' } },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
+42
View File
@@ -0,0 +1,42 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'error',
// Promote deps to error at strict stage (wave 6+)
'react-hooks/exhaustive-deps': 'error',
},
},
);
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
"npmDepsHash": "sha256-g18089KGJ4RdCag14vNLKpa048rSm1Z5XzEMVUBJKac="
}
+1619 -24
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -11,6 +11,8 @@
"tauri": "tauri",
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
"tauri:build": "npm run prebuild:release-notes && tauri build",
"lint": "eslint -c eslint.config.mjs src",
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
"test:watch": "vitest",
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
@@ -53,6 +55,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -65,8 +68,13 @@
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"esbuild": "^0.28.1",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "^8.0.14",
"vitest": "^4.1.8"
},
+2 -1
View File
@@ -15,7 +15,8 @@ import type {
} from './subsonicTypes';
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: any } }>('getArtists.view', {
type ArtistIndexEntry = { artist?: SubsonicArtist | SubsonicArtist[] };
const data = await api<{ artists?: { index?: ArtistIndexEntry | ArtistIndexEntry[] } }>('getArtists.view', {
...libraryFilterParams(),
});
const rawIdx = data.artists?.index;
-5
View File
@@ -136,11 +136,6 @@ async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | n
return ids;
}
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const { activeServerId } = useAuthStore.getState();
return activeServerId ? albumIdsInLibraryScope(activeServerId) : null;
}
export async function filterSongsToServerLibrary(
songs: SubsonicSong[],
serverId: string,
+1 -1
View File
@@ -1,5 +1,5 @@
import md5 from 'md5';
import { coverStorageKey, coverStorageKeyFromRef } from '../cover/storageKeys';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
import { coverEntryToRef, resolveAlbumCoverEntry } from '../cover/resolveEntry';
import type { CoverArtTier } from '../cover/types';
import { useAuthStore } from '../store/authStore';
-3
View File
@@ -2,7 +2,6 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react
import { useLocation, useNavigate } from 'react-router-dom';
import { ensurePlaybackServerActive } from '../utils/playback/playbackServer';
import { navigatePathWithAlbumReturnTo, shouldSkipMainScrollResetOnRouteChange } from '../utils/navigation/albumDetailNavigation';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { PanelRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -113,7 +112,6 @@ export function AppShell() {
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const hasOfflineContent = offlineCtx.hasBrowsingContent;
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
@@ -218,7 +216,6 @@ export function AppShell() {
const {
queueWidth,
isDraggingQueue,
setIsDraggingQueue,
queueHandleTop,
handleQueueHandleMouseDown,
+2 -2
View File
@@ -29,8 +29,8 @@ export function AboutPsysonicBrandHeader({
}) {
const modalWordmarkGradSuffix = useId().replace(/:/g, '');
const [phase, setPhase] = useState<'idle' | 'hint' | 'done'>('idle');
const [idleTaps, setIdleTaps] = useState(0);
const [hintTimestamps, setHintTimestamps] = useState<number[]>([]);
const [, setIdleTaps] = useState(0);
const [, setHintTimestamps] = useState<number[]>([]);
const [overlayOpen, setOverlayOpen] = useState(false);
const onLogoClick = useCallback(() => {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
import React, { useMemo, useState } from 'react';
import React from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { useCoverLightboxSrc } from '../cover/lightbox';
+10
View File
@@ -129,12 +129,18 @@ export default function AlbumRow({
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
// handleScroll/recomputeArtworkBudget are recreated each render but read live
// refs/props; the listeners are intentionally (re)bound only when the row data
// or artwork config changes, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueAlbums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
// Reset when the rows 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(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, rowArtworkResetKey]);
@@ -198,6 +204,10 @@ export default function AlbumRow({
return () => {
cancelled = true;
};
// handleScroll/recomputeArtworkBudget/onScrollRestoreComplete are recreated
// each render but read live state; the restore pass is intentionally keyed on
// the row identity / layout signals, not on those callback identities.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowArtworkResetKey, windowArtworkByViewport, initialArtworkBudget, uniqueAlbums.length]);
useLayoutEffect(() => {
+2
View File
@@ -81,6 +81,8 @@ export default function AlbumTrackList({
} = useAlbumTrackListSelection({ songs, tracklistRef });
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
+6
View File
@@ -83,12 +83,18 @@ export default function ArtistRow({
return () => {
cancelled = true;
};
// handleScroll is recreated each render but reads live refs; the restore pass
// is intentionally keyed on the row identity, not on the callback identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowResetKey, artists.length]);
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
// handleScroll is recreated each render but reads live refs; the resize
// listener is intentionally rebound only when the row data changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artists]);
const scroll = (dir: 'left' | 'right') => {
+2
View File
@@ -35,6 +35,8 @@ export default function BackToTopButton({
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
const el = document.getElementById(viewportId);
if (!el) return;
+6
View File
@@ -214,6 +214,8 @@ function useBecauseRowSlotCount(active: boolean, max = SHOW_COUNT): number {
useEffect(() => {
if (!active) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCount(1);
return;
}
@@ -370,6 +372,8 @@ export default function BecauseYouLikeRail({
* while-revalidate), only clearing to skeleton when nothing is available. */
useLayoutEffect(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnchor(_becauseReserve!.anchor);
setRecs(_becauseReserve!.recs);
setRefreshing(false);
@@ -413,6 +417,8 @@ export default function BecauseYouLikeRail({
return;
}
if (!activeServerId) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnchor(null);
setRecs([]);
setRefreshing(false);
+13
View File
@@ -43,6 +43,11 @@ type ResolvedSlice = { key: string | null; url: string };
* loading immediately. Pass false for CSS background-image consumers that
* should only see a stable blob URL (prevents a double crossfade).
*/
// useCachedUrl is the headless companion of CachedImage — it shares the same
// caching contract and the module-local ResolvedSlice type, and is documented
// alongside the component in src/CLAUDE.md. Intentional co-location; the
// fast-refresh rule is an HMR-only concern.
// eslint-disable-next-line react-refresh/only-export-components
export function useCachedUrl(
fetchUrl: string,
cacheKey: string,
@@ -54,6 +59,8 @@ export function useCachedUrl(
// `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);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
fetchUrlRef.current = fetchUrl;
// Pair blob URL with the `cacheKey` it belongs to. After `cacheKey` changes,
@@ -71,6 +78,8 @@ export function useCachedUrl(
);
const getPriorityRef = useRef(getPriority);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
getPriorityRef.current = getPriority;
useEffect(() => {
@@ -100,6 +109,8 @@ export function useCachedUrl(
const sync = acquireUrl(cacheKey);
if (sync) {
ownedKeyRef.current = cacheKey;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolvedSlice({ key: cacheKey, url: sync });
return release;
}
@@ -211,6 +222,8 @@ export default function CachedImage({
// useLayoutEffect: one paint with `loaded` still true + a stale/wrong `src`
// showed a broken-cover flash in the player bar when switching tracks (#606).
useLayoutEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoaded(false);
setFallbackSrc(undefined);
}, [cacheKey]);
-1
View File
@@ -7,7 +7,6 @@
* markup as long as the menu items + their handlers stay observable.
*/
import type { ServerProfile } from '@/store/authStoreTypes';
import type { Track } from '@/store/playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
+2
View File
@@ -109,6 +109,8 @@ export default function ContextMenu() {
useEffect(() => {
if (contextMenu.isOpen) {
cancelPlaylistSubmenuCloseTimer();
// React Compiler set-state-in-effect rule: local coords synced from the store's contextMenu position when the menu opens.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCoords({ x: contextMenu.x, y: contextMenu.y });
setPlaylistSubmenuOpen(false);
setPlaylistSongIds([]);
+4
View File
@@ -31,6 +31,10 @@ export default function Equalizer() {
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
drawCurve(canvas, gains, accent, bg, text);
// theme is an intentional re-create trigger: redraw reads the live CSS custom
// properties via getComputedStyle, so it must re-run when the theme changes
// even though the `theme` value itself is not referenced here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gains, theme]);
useEffect(() => { redraw(); }, [redraw]);
+12
View File
@@ -92,6 +92,8 @@ export default function FpsOverlay() {
} = visibility;
const sparklineNow = useMemo(
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
() => (live.sampleAt > 0 ? live.sampleAt : Date.now()),
[live.sampleAt],
);
@@ -101,6 +103,8 @@ export default function FpsOverlay() {
useEffect(() => {
if (!showAnalysisPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTpm(0);
return;
}
@@ -112,6 +116,8 @@ export default function FpsOverlay() {
useEffect(() => {
if (!showAnalysisPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setQueueStats(null);
return;
}
@@ -135,6 +141,8 @@ export default function FpsOverlay() {
useEffect(() => {
if (!showCoverPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCpm(0);
setCpmUi(0);
return;
@@ -150,6 +158,8 @@ export default function FpsOverlay() {
useEffect(() => {
if (!showCoverPerfOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCoverQueueLines([]);
return;
}
@@ -180,6 +190,8 @@ export default function FpsOverlay() {
useEffect(() => {
if (!showFpsOverlay) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setFps(0);
return;
}
+2
View File
@@ -50,6 +50,8 @@ export default function GenreFilterBar({
useEffect(() => {
if (catalogGenres != null) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setGenreRows(mergeGenreRows(catalogGenres, selected));
return;
}
+18 -2
View File
@@ -37,10 +37,14 @@ function HeroBg({ url }: { url: string }) {
);
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
latestUrlRef.current = url;
useEffect(() => {
if (!url) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLayers([]);
return;
}
@@ -102,6 +106,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const visibilityRafRef = useRef<number | null>(null);
const [heroInView, setHeroInView] = useState(true);
const heroInViewRef = useRef(true);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
heroInViewRef.current = heroInView;
const computeHeroVisibleNow = useCallback((): boolean => {
@@ -198,6 +204,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
}, [heroInView, windowHidden, updateHeroVisibility]);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (albumsProp?.length) { setAlbums(albumsProp); return; }
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
@@ -288,6 +296,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
}).catch(() => {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
});
// Intentionally keyed on album?.id only: the format label is fetched once per
// album id and cached in albumFormats. Depending on the album object or the
// albumFormats map would re-run on every render / cache write.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
@@ -305,6 +317,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
stableBgByAlbum.current[albumId] = bgHandle.src;
}
}, [bgHandle.src, albumId]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
@@ -322,6 +336,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{/* React Compiler refs rule: heroBgUrl reads a ref to mirror the latest cover URL; it is not part of reactive render data. */}
{/* eslint-disable-next-line react-hooks/refs */}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
@@ -370,7 +386,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) {}
} catch (_) { /* ignore: best-effort */ }
}}
aria-label={t('hero.enqueue')}
data-tooltip={t('hero.enqueueTooltip')}
@@ -404,7 +420,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) {}
} catch (_) { /* ignore: best-effort */ }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip={t('hero.enqueueTooltip')}
-1
View File
@@ -9,7 +9,6 @@ import {
declineOrbitSuggestion,
suggestionKey,
} from '../utils/orbit';
import { CoverArtImage } from '../cover/CoverArtImage';
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
+2
View File
@@ -171,6 +171,8 @@ export default function LicensesPanel() {
}, [data, query]);
const scrollParentRef = useRef<HTMLDivElement | null>(null);
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollParentRef.current,
+8 -2
View File
@@ -20,7 +20,7 @@ import {
import {
logLibrarySearch,
} from '../utils/library/libraryDevLog';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
@@ -42,6 +42,8 @@ import ShareSearchResults from './search/ShareSearchResults';
import {
LiveSearchScopeBadge,
LiveSearchScopeGhostBadge,
} from './search/liveSearchScopeUi';
import {
createLiveSearchScopeBackspaceState,
handleLiveSearchScopeBackspace,
handleLiveSearchScopeUndo,
@@ -50,7 +52,7 @@ import {
noteLiveSearchScopeQueryInput,
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from './search/liveSearchScopeUi';
} from './search/liveSearchScope';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
import { resolveIndexKey } from '../utils/server/serverIndexKey';
@@ -98,6 +100,8 @@ function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumI
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
return (
@@ -227,6 +231,8 @@ export default function LiveSearch() {
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResults(null);
setOpen(false);
setSearchSource(null);
+6 -6
View File
@@ -1,6 +1,6 @@
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
@@ -95,11 +95,11 @@ export default function MiniPlayer() {
const toggleOnTop = async () => {
const next = !alwaysOnTop;
setAlwaysOnTop(next);
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {}
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch { /* ignore: best-effort */ }
};
const closeMini = async () => {
try { await invoke('close_mini_player'); } catch {}
try { await invoke('close_mini_player'); } catch { /* ignore: best-effort */ }
};
const showMain = () => invoke('show_main_window').catch(() => {});
@@ -112,11 +112,11 @@ export default function MiniPlayer() {
if (!next) {
const h = Math.round(window.innerHeight);
if (h >= EXPANDED_MIN.h) {
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {}
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch { /* ignore: best-effort */ }
}
}
setQueueOpen(next);
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch { /* ignore: best-effort */ }
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
@@ -127,7 +127,7 @@ export default function MiniPlayer() {
minWidth: min.w,
minHeight: min.h,
});
} catch {}
} catch { /* ignore: best-effort */ }
};
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
+8 -2
View File
@@ -3,7 +3,7 @@ import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import type { Track } from '../store/playerStoreTypes';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
import React, { useState, useCallback, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useTranslation } from 'react-i18next';
@@ -68,6 +68,8 @@ function extractVibrantColor(imageUrl: string): Promise<string> {
function useAlbumAccentColor(imageUrl: string): string {
const [color, setColor] = useState('0,0,0');
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!imageUrl) { setColor('0,0,0'); return; }
let cancelled = false;
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
@@ -96,6 +98,8 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
// Virtualize so a multi-thousand-track queue keeps DOM at O(visible rows) on
// mobile too (matches the desktop QueuePanel).
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: queue.length,
getScrollElement: () => listRef.current,
@@ -290,10 +294,12 @@ export default function MobilePlayerView() {
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
};
}, [seekFromX]);
}, [seekFromX, seek]);
useEffect(() => {
pendingSeekRef.current = null;
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPreviewProgress(null);
}, [currentTrack?.id]);
+18 -3
View File
@@ -22,6 +22,8 @@ import ShareSearchResults from './search/ShareSearchResults';
import {
LiveSearchScopeBadge,
LiveSearchScopeGhostBadge,
} from './search/liveSearchScopeUi';
import {
createLiveSearchScopeBackspaceState,
handleLiveSearchScopeBackspace,
handleLiveSearchScopeUndo,
@@ -30,7 +32,7 @@ import {
noteLiveSearchScopeQueryInput,
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from './search/liveSearchScopeUi';
} from './search/liveSearchScope';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
@@ -63,6 +65,9 @@ function MobileSearchSongThumb({
}) {
const coverRef = useMemo(
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
// Keyed on song's identity fields; depending on the `song` object would
// recompute the ref on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[song.id, song.albumId, song.coverArt, song.discNumber],
);
if (!coverRef) return null;
@@ -80,6 +85,8 @@ function MobileSearchSongThumb({
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) {
return (
@@ -141,7 +148,13 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
return () => { document.body.style.overflow = prev; };
}, []);
// doSearch wraps a debounce() result, so the useCallback argument is not an
// inline function and its deps can't be statically analysed. It is recreated
// only on musicLibraryFilterVersion (search() reads the active filter state).
// eslint-disable-next-line react-hooks/exhaustive-deps
const doSearch = useCallback(
// React Compiler rule: memoization shape is intentional here.
// eslint-disable-next-line react-hooks/use-memo
debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setLoading(false); return; }
setLoading(true);
@@ -154,6 +167,8 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResults(null);
setLoading(false);
return;
@@ -183,7 +198,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
onClose();
};
const useRecent = (term: string) => {
const applyRecentSearch = (term: string) => {
setQuery(term, { recordUndo: true });
inputRef.current?.focus();
};
@@ -268,7 +283,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
{recentSearches.map(term => (
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
<button key={term} className="mobile-search-item" onClick={() => applyRecentSearch(term)}>
<div className="mobile-search-avatar">
<Clock size={18} />
</div>
+4 -1
View File
@@ -1,4 +1,3 @@
import { CoverArtImage } from '../cover/CoverArtImage';
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
import { getNowPlaying } from '../api/subsonicScrobble';
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
@@ -45,6 +44,8 @@ export default function NowPlayingDropdown() {
let ms = entry.positionMs;
if (entry.state === 'playing') {
const rate = entry.playbackRate && entry.playbackRate > 0 ? entry.playbackRate : 1;
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
ms += (Date.now() - fetchedAtRef.current) * rate;
}
const maxMs = entry.duration > 0 ? entry.duration * 1000 : ms;
@@ -206,6 +207,8 @@ export default function NowPlayingDropdown() {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* React Compiler refs rule: the row renderer reads a ref for latest presence state; intentional, not reactive render data. */}
{/* eslint-disable-next-line react-hooks/refs */}
{visible.map((stream, idx) => {
const presence = nowPlayingPresence(stream);
const presenceLabel = t(`nowPlaying.presence.${presence}`);
+8
View File
@@ -118,10 +118,14 @@ export default function NowPlayingInfo() {
const bioRef = useRef<HTMLParagraphElement | null>(null);
// Reset per-track UI state when the track changes
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]);
// Artist bio + image
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
const cached = artistInfoCache.get(cacheKey);
@@ -136,6 +140,8 @@ export default function NowPlayingInfo() {
// Song detail (for OpenSubsonic contributors[])
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetailEntry(null); return; }
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
const cached = songDetailCache.get(cacheKey);
@@ -150,6 +156,8 @@ export default function NowPlayingInfo() {
// Bandsintown — only when opt-in toggle is on
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
let cancelled = false;
setTourLoading(true);
+2
View File
@@ -19,6 +19,8 @@ export default function OrbitAccountPicker() {
// Reset + focus first item each time the picker re-opens.
useEffect(() => {
if (!isOpen) return;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelected(0);
// Defer focus to the next tick so the DOM has actually mounted.
queueMicrotask(() => itemRefs.current[0]?.focus());
@@ -65,6 +65,8 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
};
}, [anchorRef, onClose]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
-1
View File
@@ -48,7 +48,6 @@ export default function OrbitExitModal() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
if (!isOpen) return null;
+1 -1
View File
@@ -25,7 +25,7 @@ export default function OrbitGuestQueue() {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const pending = useOrbitStore(s => s.pendingSuggestions);
const queueItems = state?.playQueue ?? [];
const queueItems = useMemo(() => state?.playQueue ?? [], [state?.playQueue]);
const totalUpcoming = state?.playQueueTotal ?? queueItems.length;
const truncatedBy = Math.max(0, totalUpcoming - queueItems.length);
const currentTrack = state?.currentTrack ?? null;
@@ -28,6 +28,8 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
const role = useOrbitStore(s => s.role);
const popRef = useRef<HTMLDivElement>(null);
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
const nowMs = Date.now();
// Close on outside click / Escape — unless a confirm dialog is open
@@ -55,6 +57,8 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
if (!state) return null;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
+3 -1
View File
@@ -97,6 +97,8 @@ export default function OrbitSessionBar() {
if (role !== 'guest' || !state || !state.currentTrack) {
overSinceRef.current = null;
underSinceRef.current = null;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowCatchUp(false);
return;
}
@@ -132,7 +134,7 @@ export default function OrbitSessionBar() {
overSinceRef.current = null;
}
}
}, [role, state, nowMs, showCatchUp]);
}, [role, state, nowMs, showCatchUp, SHOW_THRESHOLD_MS]);
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
const shouldShowBar = !!state && (
+2
View File
@@ -38,6 +38,8 @@ export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) {
};
}, [anchorRef, onClose]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
+2
View File
@@ -57,6 +57,8 @@ export default function OrbitSharePopover({ anchorRef, onClose }: Props) {
if (!shareLink) return null;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
+2
View File
@@ -47,6 +47,8 @@ export default function OrbitStartModal({ onClose }: Props) {
const shareLink = useMemo(
() => buildOrbitShareLink(serverBase, sid),
// React Compiler rule: manual memoization is intentional and must be preserved.
// eslint-disable-next-line react-hooks/preserve-manual-memoization
[serverBase, sid],
);
+2
View File
@@ -47,6 +47,8 @@ export default function OrbitStartTrigger() {
if (role !== null) return null;
if (!visible) return null;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const anchor = btnRef.current?.getBoundingClientRect();
const popoverStyle: React.CSSProperties = anchor
? {
+2
View File
@@ -25,6 +25,8 @@ interface Props {
*/
export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore, showBpm }: Props) {
const onLoadMoreRef = useRef(onLoadMore);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
onLoadMoreRef.current = onLoadMore;
const bindSentinel = useInpageScrollSentinel({
+4
View File
@@ -198,6 +198,10 @@ export default function PasteClipboardHandler() {
};
document.addEventListener('paste', onPaste, true);
return () => document.removeEventListener('paste', onPaste, true);
// handleJoinError and the router location are captured by the onPaste closure;
// the global paste listener is intentionally not re-registered on every render
// or navigation, only when the auth/handler inputs below change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigate, t, isLoggedIn, queuePaste]);
const closeQueuePaste = () => {
+4
View File
@@ -86,6 +86,8 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
useEffect(() => {
if (!open) return;
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCustomMinutes('');
setHoverSeconds(null);
}, [open]);
@@ -145,6 +147,8 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
};
const useAnchor = !!anchorRef;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const anchorEl = anchorRef?.current ?? null;
void posTick;
const anchoredPanelStyle =
+2
View File
@@ -51,6 +51,8 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
useEffect(() => {
if (deadlineMs == null) return;
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setNowMs(Date.now());
}, [deadlineMs]);
+2 -11
View File
@@ -1,8 +1,7 @@
import { queueSongStar } from '../store/pendingStarSync';
import { coverArtIdFromRadio } from '../cover/ids';
import { resolvePlaybackTrackCoverArtId } from '../cover/resolveCoverArtId';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
SlidersVertical, X,
@@ -13,23 +12,18 @@ import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { useRadioMprisSync } from '../hooks/useRadioMprisSync';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { coerceOpenArtistRefs } from '../utils/openArtistRefs';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
import { formatTrackTime } from '../utils/format/formatDuration';
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection';
@@ -53,15 +47,12 @@ export default function PlayerBar() {
const [showVolPct, setShowVolPct] = useState(false);
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
const premuteVolumeRef = useRef(1);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
const {
currentTrack, currentRadio, isPlaying, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
networkLoved, toggleNetworkLove,
isQueueVisible, toggleQueue,
starredOverrides,
userRatingOverrides,
openContextMenu,
@@ -177,7 +168,7 @@ export default function PlayerBar() {
volumeWheelMenuTimerRef.current = null;
}, 1000);
}
}, [volume, setVolume, utilityOverflow]);
}, [volume, setVolume, utilityOverflow, setSuppressOverflowTooltip, setUtilityMenuMode, setUtilityMenuOpen, volumeWheelMenuTimerRef]);
const volumeStyle = {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${volume * 100}%)`,
-1
View File
@@ -21,7 +21,6 @@ import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore';
import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
import { useLuckyMixStore } from '../store/luckyMixStore';
import { useQueueToolbarStore } from '../store/queueToolbarStore';
import { SavePlaylistModal } from './queuePanel/SavePlaylistModal';
+4
View File
@@ -144,8 +144,12 @@ export default function Sidebar({
);
const sidebarItemsRef = useRef(sidebarItems);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
sidebarItemsRef.current = sidebarItems;
const randomNavModeRef = useRef(randomNavMode);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
randomNavModeRef.current = randomNavMode;
const {
+2
View File
@@ -80,6 +80,8 @@ export default function SongInfoModal() {
useEffect(() => {
if (!songInfoModal.isOpen || !songInfoModal.songId) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSong(null);
setEnrichment(null);
setAbsolutePath(null);
+6
View File
@@ -84,10 +84,16 @@ export default function SongRail({
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
// handleScroll/recomputeArtworkBudget are recreated each render but read live
// refs/props; the listeners are intentionally (re)bound only when the row data
// or artwork config changes, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
const rowArtworkResetKey = uniqueSongs[0]?.id ?? '';
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, rowArtworkResetKey]);
+4
View File
@@ -40,6 +40,8 @@ export default function StarRating({
const cappedValue = Math.min(Math.max(0, value), selectCap);
React.useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (value > 0) setSuppressHoverPreview(false);
}, [value]);
@@ -68,6 +70,8 @@ export default function StarRating({
if (next < prev) {
const star = Math.max(1, Math.min(selectCap, prev));
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (next === 0) setSuppressHoverPreview(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setClearShrinkStar(star));
+2
View File
@@ -53,6 +53,8 @@ export default function StatsExportModal({ open, albums, meta, onClose }: Props)
useEffect(() => {
if (!open) return;
if (albums.length >= MAX_NEEDED) {
// React Compiler set-state-in-effect rule: local state synced with the already-available `albums` prop when the modal opens (the async top-up below is skipped).
// eslint-disable-next-line react-hooks/set-state-in-effect
setTopUpAlbums(albums);
return;
}
+2
View File
@@ -90,6 +90,8 @@ export function VirtualCardGrid<T>({
deps: [layoutSignal, virtualRowCount, scrollRootId],
});
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: disableVirtualization ? 0 : virtualRowCount,
getScrollElement,
+26
View File
@@ -39,7 +39,11 @@ export default function WaveformSeek({ trackId }: Props) {
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(getPlaybackProgressSnapshot().progress);
const bufferedRef = useRef(getPlaybackProgressSnapshot().buffered);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const visualProgressRef = useRef(progressRef.current);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const visualTargetProgressRef = useRef(progressRef.current);
const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState());
@@ -60,6 +64,8 @@ export default function WaveformSeek({ trackId }: Props) {
// Ref so the subscription callback (closed over at mount) can read the
// current style without stale-closure issues.
const styleRef = useRef(seekbarStyle);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
styleRef.current = seekbarStyle;
useWaveformHeights({
@@ -185,6 +191,8 @@ export default function WaveformSeek({ trackId }: Props) {
// When preview ends, interpolation must restart from "now", otherwise the
// old anchor timestamp adds preview duration and causes a one-frame jump.
useEffect(() => {
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: progressRef.current,
atMs: performance.now(),
@@ -241,13 +249,21 @@ export default function WaveformSeek({ trackId }: Props) {
}, [seekbarStyle]);
const trackIdRef = useRef(trackId);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
trackIdRef.current = trackId;
const seekRef = useRef(seek);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
const progressAnchorRef = useRef<{ progress: number; atMs: number }>({
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
progress: progressRef.current,
// React Compiler purity rule: intentional live-timestamp read at render (performance.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
atMs: performance.now(),
});
const wheelSeekTimerRef = useRef<number | null>(null);
@@ -278,6 +294,8 @@ export default function WaveformSeek({ trackId }: Props) {
progressRef.current = fraction;
visualProgressRef.current = fraction;
visualTargetProgressRef.current = fraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: fraction,
atMs: performance.now(),
@@ -293,6 +311,8 @@ export default function WaveformSeek({ trackId }: Props) {
const fraction = pendingSeekRef.current;
if (fraction === null) return;
pendingSeekRef.current = null;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
pendingCommittedSeekRef.current = { fraction, setAtMs: Date.now() };
seekRef.current(fraction);
};
@@ -348,11 +368,17 @@ export default function WaveformSeek({ trackId }: Props) {
progressRef.current = nextFraction;
visualProgressRef.current = nextFraction;
visualTargetProgressRef.current = nextFraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = {
progress: nextFraction,
atMs: performance.now(),
};
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
wheelPreviewFractionRef.current = nextFraction;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
@@ -63,7 +63,6 @@ export function TracklistColumnPicker({
// Position before paint so the menu appears in place.
useLayoutEffect(() => {
if (pickerOpen) updatePos();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pickerOpen]);
// Keep anchored on scroll/resize, and close on outside-click / Escape.
@@ -24,6 +24,8 @@ export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const songIdsRef = useRef(songIds);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
songIdsRef.current = songIds;
const [adding, setAdding] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
@@ -14,6 +14,8 @@ export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProp
useEffect(() => {
const serverId = resolveMediaServerId();
if (!serverId) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolvedIds([]);
return;
}
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
import { resolveAlbum, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
import { star, unstar } from '../../api/subsonicStarRating';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { songToTrack } from '../../utils/playback/songToTrack';
import StarRating from '../StarRating';
import { AlbumToPlaylistSubmenu } from './AlbumArtistToPlaylistSubmenu';
@@ -13,19 +12,15 @@ import type { ContextMenuItemsProps } from './contextMenuItemTypes';
export default function AlbumContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
type, item, playNext, enqueue, closeContextMenu,
setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
entityRatingSupport, applyAlbumRating,
handleAction, downloadAlbum, copyShareLink, isStarred,
pinToPlaybackServer, navigateLibrary, offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
const navigate = useNavigate();
const goLibrary = pinToPlaybackServer ? navigateLibrary : (path: string) => { navigate(path); };
@@ -1,9 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Radio, Heart, ChevronRight, ListMusic, Star, Share2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { star, unstar } from '../../api/subsonicStarRating';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import StarRating from '../StarRating';
import { ArtistToPlaylistSubmenu } from './AlbumArtistToPlaylistSubmenu';
import { MultiArtistToPlaylistSubmenu } from './MultiArtistToPlaylistSubmenu';
@@ -11,20 +9,15 @@ import type { ContextMenuItemsProps } from './contextMenuItemTypes';
export default function ArtistContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
type, item, shareKindOverride, closeContextMenu,
setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
entityRatingSupport, applyArtistRating,
handleAction, startRadio, copyShareLink, isStarred,
offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
const navigate = useNavigate();
return (
<>
@@ -23,6 +23,8 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
const [showLoading, setShowLoading] = useState(false);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTotalAlbums(albumIds.length);
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
@@ -196,5 +198,7 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
);
}
if (resolvedIds.length === 0) return null;
// React Compiler rule: component intentionally defined inline for closure access.
// eslint-disable-next-line react-hooks/static-components
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
@@ -24,6 +24,8 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
const [showLoading, setShowLoading] = useState(false);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTotalArtists(artistIds.length);
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
@@ -210,5 +212,7 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
);
}
if (resolvedIds.length === 0) return null;
// React Compiler rule: component intentionally defined inline for closure access.
// eslint-disable-next-line react-hooks/static-components
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
@@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next';
import { Play, ChevronRight, FolderTree, ListMusic, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { usePlaylistStore } from '../../store/playlistStore';
import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from './PlaylistToPlaylistSubmenus';
import MoveToFolderSubmenu from './MoveToFolderSubmenu';
@@ -10,19 +9,13 @@ import type { ContextMenuItemsProps } from './contextMenuItemTypes';
export default function PlaylistContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
type, item, closeContextMenu,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
handleAction,
offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
const navigate = useNavigate();
return (
@@ -11,15 +11,15 @@ import type { ContextMenuItemsProps } from './contextMenuItemTypes';
export default function QueueItemContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
starredOverrides, networkLovedCache, setNetworkLovedForSong,
type, item, queueIndex,
playTrack, removeTrack, closeContextMenu,
networkLovedCache, setNetworkLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
audiomuseNavidromeEnabled,
applySongRating,
handleAction, startRadio, startInstantMix, copyShareLink, isStarred,
navigateLibrary,
} = props;
const { t } = useTranslation();
@@ -18,15 +18,15 @@ import type { ContextMenuItemsProps } from './contextMenuItemTypes';
export default function SongContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
starredOverrides, networkLovedCache, setNetworkLovedForSong,
type, item, playlistId, playlistSongIndex,
playTrack, playNext, enqueue, closeContextMenu,
networkLovedCache, setNetworkLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
orbitRole, audiomuseNavidromeEnabled,
applySongRating,
handleAction, startRadio, startInstantMix, copyShareLink, isStarred,
offlinePolicy,
} = props;
const { t } = useTranslation();
@@ -140,6 +140,8 @@ export default function FavoritesSongsSectionHeader({
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
songIds={pickerSongIdsRef.current}
resolveSongIds={() => pickerSongIdsRef.current}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
@@ -142,6 +142,9 @@ export default function FavoritesSongsTracklist({
const [scrollMargin, setScrollMargin] = useState(0);
const viewportH = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
// Bulk bar show/hide shifts listWrapRef top — remeasure on that edge only.
const bulkBarVisible = selectedIds.size > 0;
useLayoutEffect(() => {
const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
// scrollMargin must track height changes in sections above the list (filters, top artists).
@@ -159,9 +162,10 @@ export default function FavoritesSongsTracklist({
if (root) ro.observe(root);
measure();
return () => ro.disconnect();
// selectedIds.size > 0 (boolean): bulk bar show/hide shifts listWrapRef top — remeasure on that edge only.
}, [tracklistRef, selectedIds.size > 0, pickerOpen, visibleSongs.length]);
}, [tracklistRef, bulkBarVisible, pickerOpen, visibleSongs.length]);
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: visibleSongs.length,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
import { coverServerScopeForServerId } from '../../cover/serverScope';
+1 -1
View File
@@ -16,7 +16,7 @@ export const FsClock = memo(function FsClock() {
const [time, setTime] = useState(formatNow);
useEffect(() => {
setTime(formatNow());
setTime(formatClockTime(Date.now(), clockFormat, i18n.language));
}, [clockFormat, i18n.language]);
useEffect(() => {
@@ -52,6 +52,8 @@ export default function RadioDirectoryModal({ onClose, onAdded }: RadioDirectory
// Load top stations on open
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchPage('', 0, false);
}, [fetchPage]);
+2
View File
@@ -38,6 +38,8 @@ export function MiniQueue({
}: Props) {
// Virtualize so a multi-thousand-track queue keeps the mini window's DOM at
// O(visible rows). Scroll element is the OverlayScrollArea viewport.
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: state.queue.length,
getScrollElement: () => queueScrollRef.current,
@@ -93,6 +93,8 @@ export default function PerfOverlaySparkline({
const peakRef = useRef(1);
const { path, areaPath } = useMemo(() => {
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const bounds = stableScale(samples, kind, peakRef);
const paths = sparklinePaths(samples, now, windowMs, width, height, bounds.min, bounds.max);
return { path: paths.line, areaPath: paths.area };
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { Camera, Loader2, X } from 'lucide-react';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import type { CoverArtId } from '../../cover/types';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
-1
View File
@@ -15,7 +15,6 @@ import {
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
} from '../../utils/componentHelpers/playlistDetailHelpers';
import type { CoverArtId } from '../../cover/types';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
@@ -130,7 +130,8 @@ export default function PlaylistSongSearchPanel({
onClick={e => e.stopPropagation()}
onChange={() => setSelectedSearchIds(prev => {
const next = new Set(prev);
next.has(song.id) ? next.delete(song.id) : next.add(song.id);
if (next.has(song.id)) next.delete(song.id);
else next.add(song.id);
return next;
})}
/>
@@ -163,6 +163,9 @@ export default function PlaylistTracklist({
const [scrollMargin, setScrollMargin] = useState(0);
const viewportH = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
// Bulk bar show/hide shifts listWrapRef top — remeasure on that edge only.
const bulkBarVisible = selectedIds.size > 0;
useLayoutEffect(() => {
const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const root = tracklistRef.current?.closest('.album-detail') as HTMLElement | null;
@@ -178,8 +181,10 @@ export default function PlaylistTracklist({
if (root) ro.observe(root);
measure();
return () => ro.disconnect();
}, [tracklistRef, selectedIds.size > 0, pickerOpen, displayedSongs.length]);
}, [tracklistRef, bulkBarVisible, pickerOpen, displayedSongs.length]);
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: displayedSongs.length,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
@@ -28,6 +28,8 @@ export function LoadPlaylistModal({ onClose, onLoad }: Props) {
};
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchPlaylists();
}, []);
@@ -77,6 +77,8 @@ export function QueueHeader({
if (queue.length > 0) {
if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs));
else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`;
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
else dur = formatClockTime(Date.now() + remainingSecs * 1000, clockFormat, i18n.language);
}
+2
View File
@@ -68,6 +68,8 @@ export function QueueList({
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows).
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have
// variable height (radio/auto dividers, lucky-mix loader) so we measure them.
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: queue.length,
getScrollElement: () => queueListRef.current,
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
Blend, Check, FolderOpen, Infinity, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
Blend, Check, FolderOpen, Infinity as InfinityIcon, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
} from 'lucide-react';
import type { TFunction } from 'i18next';
import type { QueueItemRef } from '../../store/playerStoreTypes';
@@ -225,7 +225,7 @@ export function QueueToolbar({
data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')}
>
<Infinity size={13} />
<InfinityIcon size={13} />
</button>
);
default:
@@ -36,9 +36,9 @@ interface Props {
}
export default function RandomMixTrackRow({
song, idx, gridTemplateColumns, track, queueSongs,
song, idx, gridTemplateColumns, track,
isCurrentTrack, isPlaying, isContextActive, orbitActive,
previewingId, previewAudioStarted, starredOverrides, isStarred,
previewingId, previewAudioStarted, isStarred,
customGenreBlacklist, addedArtist, addedGenre, showGenreCol, isGenreBlocked,
onPlay, onQueueHint, onAddTrackToOrbit, onOpenContextMenu, onToggleStar,
onBlacklistArtist, onBlacklistGenre,
@@ -9,7 +9,6 @@ import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
import OverlayScrollArea from '../OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../../cover/types';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
+3 -1
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import type { ServerProfile } from '../../store/authStoreTypes';
import { songToTrack } from '../../utils/playback/songToTrack';
import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload';
@@ -80,6 +80,8 @@ function ShareArtistThumb({
coverServer?: ServerProfile | null;
}) {
const [failed, setFailed] = useState(false);
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) {
+175
View File
@@ -0,0 +1,175 @@
import type { KeyboardEvent, MouseEvent } from 'react';
import { ALL_NAV_ITEMS } from '../../config/navItems';
import type { LiveSearchScope } from '../../store/liveSearchScopeStore';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '../../store/albumBrowseSessionStore';
import { isTracksBrowsePath } from '../../store/advancedSearchSessionStore';
import { isArtistsBrowsePath } from '../../store/artistBrowseSessionStore';
import { isComposersBrowsePath } from '../../store/composerBrowseSessionStore';
export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
artists: 'artists',
albums: 'allAlbums',
newReleases: 'newReleases',
tracks: 'tracks',
composers: 'composers',
};
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
export function resolveLiveSearchScopeGhost(
pathname: string,
activeScope: LiveSearchScope | null,
): LiveSearchScope | null {
if (activeScope != null) return null;
if (isArtistsBrowsePath(pathname)) return 'artists';
if (isAlbumsBrowsePath(pathname)) return 'albums';
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
if (isTracksBrowsePath(pathname)) return 'tracks';
if (isComposersBrowsePath(pathname)) return 'composers';
return null;
}
export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsPlaceholder';
case 'albums':
return 'search.scopeAlbumsPlaceholder';
case 'newReleases':
return 'search.scopeNewReleasesPlaceholder';
case 'tracks':
return 'search.scopeTracksPlaceholder';
case 'composers':
return 'search.scopeComposersPlaceholder';
default:
return 'search.placeholder';
}
}
/** Scoped browse mode filters the page only — no live-search dropdown. */
export function isLiveSearchDropdownBlocked(scope: LiveSearchScope | null): boolean {
return scope != null;
}
export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsBadgeTooltip';
case 'albums':
return 'search.scopeAlbumsBadgeTooltip';
case 'newReleases':
return 'search.scopeNewReleasesBadgeTooltip';
case 'tracks':
return 'search.scopeTracksBadgeTooltip';
case 'composers':
return 'search.scopeComposersBadgeTooltip';
default:
return 'search.scopeArtistsBadgeTooltip';
}
}
export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsGhostTooltip';
case 'albums':
return 'search.scopeAlbumsGhostTooltip';
case 'newReleases':
return 'search.scopeNewReleasesGhostTooltip';
case 'tracks':
return 'search.scopeTracksGhostTooltip';
case 'composers':
return 'search.scopeComposersGhostTooltip';
default:
return 'search.scopeArtistsGhostTooltip';
}
}
/** Tracks Backspace-on-empty badge removal (double after prior text input). */
export type LiveSearchScopeBackspaceState = {
hadQueryInput: boolean;
emptyBackspaceStreak: number;
};
export function createLiveSearchScopeBackspaceState(): LiveSearchScopeBackspaceState {
return { hadQueryInput: false, emptyBackspaceStreak: 0 };
}
export function resetLiveSearchScopeBackspaceState(state: LiveSearchScopeBackspaceState): void {
state.hadQueryInput = false;
state.emptyBackspaceStreak = 0;
}
/** Call when the scoped field query changes (typing, paste, clear button, undo). */
export function noteLiveSearchScopeQueryInput(
state: LiveSearchScopeBackspaceState,
query: string,
): void {
if (query !== '') state.hadQueryInput = true;
}
/**
* Backspace on an empty scoped field removes the badge.
* After the user typed text (even if cleared), two consecutive Backspaces on empty are required.
*/
export function handleLiveSearchScopeBackspace(
e: KeyboardEvent<HTMLInputElement>,
query: string,
scope: LiveSearchScope | null,
clearScope: (options?: { recordUndo?: boolean }) => void,
state: LiveSearchScopeBackspaceState,
): boolean {
if (e.key !== 'Backspace' || !scope) return false;
if (query !== '') {
state.emptyBackspaceStreak = 0;
return false;
}
e.preventDefault();
if (!state.hadQueryInput) {
clearScope({ recordUndo: true });
resetLiveSearchScopeBackspaceState(state);
return true;
}
state.emptyBackspaceStreak += 1;
if (state.emptyBackspaceStreak >= 2) {
clearScope({ recordUndo: true });
resetLiveSearchScopeBackspaceState(state);
return true;
}
return true;
}
/** Single click removes the scope badge. */
export function handleLiveSearchScopeBadgeClick(
e: MouseEvent<HTMLElement>,
clearScope: (options?: { recordUndo?: boolean }) => void,
): void {
e.preventDefault();
e.stopPropagation();
clearScope({ recordUndo: true });
}
/** Single click restores scope after the user cleared the badge on this browse route. */
export function handleLiveSearchScopeGhostClick(
e: MouseEvent<HTMLElement>,
scope: LiveSearchScope,
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void,
): void {
e.preventDefault();
e.stopPropagation();
setScope(scope, { recordUndo: true });
}
/** Field-local undo (Ctrl/Cmd+Z) for live search query and scope badge. */
export function handleLiveSearchScopeUndo(
e: KeyboardEvent<HTMLInputElement>,
undo: () => boolean,
): boolean {
const isUndoKey = e.code === 'KeyZ' || e.key.toLowerCase() === 'z';
if (!isUndoKey || !(e.ctrlKey || e.metaKey) || e.shiftKey) return false;
e.preventDefault();
return undo();
}
@@ -11,7 +11,7 @@ import {
noteLiveSearchScopeQueryInput,
resetLiveSearchScopeBackspaceState,
resolveLiveSearchScopeGhost,
} from './liveSearchScopeUi';
} from './liveSearchScope';
function keyEvent(
key: string,
+7 -173
View File
@@ -1,89 +1,13 @@
import type { KeyboardEvent, MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { ALL_NAV_ITEMS } from '../../config/navItems';
import type { LiveSearchScope } from '../../store/liveSearchScopeStore';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '../../store/albumBrowseSessionStore';
import { isTracksBrowsePath } from '../../store/advancedSearchSessionStore';
import { isArtistsBrowsePath } from '../../store/artistBrowseSessionStore';
import { isComposersBrowsePath } from '../../store/composerBrowseSessionStore';
const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
artists: 'artists',
albums: 'allAlbums',
newReleases: 'newReleases',
tracks: 'tracks',
composers: 'composers',
};
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
export function resolveLiveSearchScopeGhost(
pathname: string,
activeScope: LiveSearchScope | null,
): LiveSearchScope | null {
if (activeScope != null) return null;
if (isArtistsBrowsePath(pathname)) return 'artists';
if (isAlbumsBrowsePath(pathname)) return 'albums';
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
if (isTracksBrowsePath(pathname)) return 'tracks';
if (isComposersBrowsePath(pathname)) return 'composers';
return null;
}
export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsPlaceholder';
case 'albums':
return 'search.scopeAlbumsPlaceholder';
case 'newReleases':
return 'search.scopeNewReleasesPlaceholder';
case 'tracks':
return 'search.scopeTracksPlaceholder';
case 'composers':
return 'search.scopeComposersPlaceholder';
default:
return 'search.placeholder';
}
}
/** Scoped browse mode filters the page only — no live-search dropdown. */
export function isLiveSearchDropdownBlocked(scope: LiveSearchScope | null): boolean {
return scope != null;
}
export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsBadgeTooltip';
case 'albums':
return 'search.scopeAlbumsBadgeTooltip';
case 'newReleases':
return 'search.scopeNewReleasesBadgeTooltip';
case 'tracks':
return 'search.scopeTracksBadgeTooltip';
case 'composers':
return 'search.scopeComposersBadgeTooltip';
default:
return 'search.scopeArtistsBadgeTooltip';
}
}
export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
switch (scope) {
case 'artists':
return 'search.scopeArtistsGhostTooltip';
case 'albums':
return 'search.scopeAlbumsGhostTooltip';
case 'newReleases':
return 'search.scopeNewReleasesGhostTooltip';
case 'tracks':
return 'search.scopeTracksGhostTooltip';
case 'composers':
return 'search.scopeComposersGhostTooltip';
default:
return 'search.scopeArtistsGhostTooltip';
}
}
import {
SCOPE_NAV_ITEM,
handleLiveSearchScopeBadgeClick,
handleLiveSearchScopeGhostClick,
liveSearchScopeBadgeTooltipKey,
liveSearchScopeGhostTooltipKey,
} from './liveSearchScope';
type LiveSearchScopeIconProps = {
scope: LiveSearchScope;
@@ -96,85 +20,6 @@ export function LiveSearchScopeIcon({ scope, size = 14 }: LiveSearchScopeIconPro
return <Icon size={size} aria-hidden />;
}
/** Tracks Backspace-on-empty badge removal (double after prior text input). */
export type LiveSearchScopeBackspaceState = {
hadQueryInput: boolean;
emptyBackspaceStreak: number;
};
export function createLiveSearchScopeBackspaceState(): LiveSearchScopeBackspaceState {
return { hadQueryInput: false, emptyBackspaceStreak: 0 };
}
export function resetLiveSearchScopeBackspaceState(state: LiveSearchScopeBackspaceState): void {
state.hadQueryInput = false;
state.emptyBackspaceStreak = 0;
}
/** Call when the scoped field query changes (typing, paste, clear button, undo). */
export function noteLiveSearchScopeQueryInput(
state: LiveSearchScopeBackspaceState,
query: string,
): void {
if (query !== '') state.hadQueryInput = true;
}
/**
* Backspace on an empty scoped field removes the badge.
* After the user typed text (even if cleared), two consecutive Backspaces on empty are required.
*/
export function handleLiveSearchScopeBackspace(
e: KeyboardEvent<HTMLInputElement>,
query: string,
scope: LiveSearchScope | null,
clearScope: (options?: { recordUndo?: boolean }) => void,
state: LiveSearchScopeBackspaceState,
): boolean {
if (e.key !== 'Backspace' || !scope) return false;
if (query !== '') {
state.emptyBackspaceStreak = 0;
return false;
}
e.preventDefault();
if (!state.hadQueryInput) {
clearScope({ recordUndo: true });
resetLiveSearchScopeBackspaceState(state);
return true;
}
state.emptyBackspaceStreak += 1;
if (state.emptyBackspaceStreak >= 2) {
clearScope({ recordUndo: true });
resetLiveSearchScopeBackspaceState(state);
return true;
}
return true;
}
/** Single click removes the scope badge. */
export function handleLiveSearchScopeBadgeClick(
e: MouseEvent<HTMLElement>,
clearScope: (options?: { recordUndo?: boolean }) => void,
): void {
e.preventDefault();
e.stopPropagation();
clearScope({ recordUndo: true });
}
/** Single click restores scope after the user cleared the badge on this browse route. */
export function handleLiveSearchScopeGhostClick(
e: MouseEvent<HTMLElement>,
scope: LiveSearchScope,
setScope: (scope: LiveSearchScope, options?: { recordUndo?: boolean }) => void,
): void {
e.preventDefault();
e.stopPropagation();
setScope(scope, { recordUndo: true });
}
type LiveSearchScopeBadgeProps = {
scope: LiveSearchScope;
className: string;
@@ -224,14 +69,3 @@ export function LiveSearchScopeGhostBadge({ scope, className, setScope }: LiveSe
</span>
);
}
/** Field-local undo (Ctrl/Cmd+Z) for live search query and scope badge. */
export function handleLiveSearchScopeUndo(
e: KeyboardEvent<HTMLInputElement>,
undo: () => boolean,
): boolean {
const isUndoKey = e.code === 'KeyZ' || e.key.toLowerCase() === 'z';
if (!isUndoKey || !(e.ctrlKey || e.metaKey) || e.shiftKey) return false;
e.preventDefault();
return undo();
}
@@ -96,6 +96,8 @@ export function AddServerForm({
useEffect(() => {
if (!initialInvite) return;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setBlockPasswordReveal(true);
setForm(f => ({
...f,
+2 -2
View File
@@ -154,7 +154,7 @@ export function AppearanceTab() {
<WindowButtonPreview
key={style}
style={style}
label={t(`settings.windowButtons${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
label={t(`settings.windowButtons${style.charAt(0).toUpperCase() + style.slice(1)}`)}
selected={auth.windowButtonStyle === style}
onClick={() => auth.setWindowButtonStyle(style)}
/>
@@ -301,7 +301,7 @@ export function AppearanceTab() {
<SeekbarPreview
key={style}
style={style}
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}`)}
selected={auth.seekbarStyle === style}
onClick={() => auth.setSeekbarStyle(style)}
/>
@@ -42,9 +42,13 @@ export function ArtistLayoutCustomizer() {
const [dropTarget, setDropTarget] = useState<ArtistDropTarget>(null);
const dropTargetRef = useRef<ArtistDropTarget>(null);
const sectionsRef = useRef(sections);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
sectionsRef.current = sections;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
@@ -48,9 +48,13 @@ export function LyricsSourcesCustomizer() {
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
const dropTargetRef = useRef<LyricsDropTarget>(null);
const sourcesRef = useRef(lyricsSources);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
sourcesRef.current = lyricsSources;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
+2 -2
View File
@@ -34,8 +34,8 @@ export function LyricsTab() {
<div key={style}>
{i > 0 && <div className="settings-section-divider" />}
<SettingsToggle
label={t(`settings.sidebarLyricsStyle${key}` as any)}
desc={t(`settings.sidebarLyricsStyle${key}Desc` as any)}
label={t(`settings.sidebarLyricsStyle${key}`)}
desc={t(`settings.sidebarLyricsStyle${key}Desc`)}
checked={sidebarLyricsStyle === style}
onChange={c => setSidebarLyricsStyle(c ? style : other)}
/>
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Blend, GripVertical, Infinity, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { Blend, GripVertical, Infinity as InfinityIcon, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { useQueueToolbarStore, QueueToolbarButtonId } from '../../store/queueToolbarStore';
@@ -15,7 +15,7 @@ const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle |
gapless: MoveRight,
crossfade: Waves,
autodj: Blend,
infinite: Infinity,
infinite: InfinityIcon,
};
const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
@@ -56,9 +56,13 @@ export function QueueToolbarCustomizer() {
const [dropTarget, setDropTarget] = useState<QueueToolbarDropTarget>(null);
const dropTargetRef = useRef<QueueToolbarDropTarget>(null);
const buttonsRef = useRef(buttons);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
buttonsRef.current = buttons;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
+6
View File
@@ -82,6 +82,8 @@ export function ServersTab({
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
const serverDropTargetRef = useRef<ServerDropTarget>(null);
const serversRef = useRef(auth.servers);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
serversRef.current = auth.servers;
const addServerInviteAnchorRef = useRef<HTMLDivElement>(null);
@@ -94,6 +96,8 @@ export function ServersTab({
// ServersTab is already mounted (initial mount is handled via useState).
useEffect(() => {
if (initialInvite) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPastedServerInvite(initialInvite);
setShowAddForm(true);
}
@@ -103,6 +107,8 @@ export function ServersTab({
useEffect(() => {
if (!psyDragState.isDragging) {
serverDropTargetRef.current = null;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setServerDropTarget(null);
}
}, [psyDragState.isDragging]);
@@ -38,6 +38,8 @@ export function SidebarCustomizer() {
const [dropTarget, setDropTarget] = useState<DropTarget>(null);
const dropTargetRef = useRef<DropTarget>(null);
const itemsRef = useRef(items);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode);
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
@@ -59,6 +61,8 @@ export function SidebarCustomizer() {
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
@@ -91,6 +91,8 @@ export function ThemeStoreSection() {
const thumbUrl = (rel: string) =>
generatedAt ? `${assetUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : assetUrl(rel);
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(false); }, []);
const installedMap = useMemo(() => {
@@ -123,6 +125,8 @@ export function ThemeStoreSection() {
// A changed filter can shrink the result set below the current page; reset to
// the first page whenever the query or mode filter changes.
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setPage(1); }, [query, mode, sortMode, animFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
+5 -1
View File
@@ -20,7 +20,7 @@ export interface UserFormState {
libraryIds: number[];
}
export function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]): UserFormState {
function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]): UserFormState {
const defaultIds = allLibraries.map(l => l.id);
return {
userName: u?.userName ?? '',
@@ -61,11 +61,15 @@ export function UserForm({
const isEdit = !!initial;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowNewUserRequiredErrors(false);
}, [initial?.id]);
useEffect(() => {
if (!isEdit && form.userName.trim() && form.name.trim() && form.password.trim()) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowNewUserRequiredErrors(false);
}
}, [isEdit, form.userName, form.name, form.password]);
@@ -32,6 +32,8 @@ export function MusicNetworkSection() {
// Profile stats (scrobbles / member-since) for the enrichment primary.
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!enrichmentPrimaryId) { setPrimaryProfile(null); return; }
let cancelled = false;
setPrimaryProfile(null);
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { TFunction } from 'i18next';
import { matchScore } from './settingsTabs';
import { searchSettings } from './settingsSearch';
@@ -31,17 +32,17 @@ describe('matchScore', () => {
describe('searchSettings', () => {
it('finds AudioMuse by product name', () => {
const hits = searchSettings('AudioMuse', 'library', t as any);
const hits = searchSettings('AudioMuse', 'library', t as unknown as TFunction);
expect(hits.some(h => h.title.includes('AudioMuse'))).toBe(true);
});
it('finds global volume shortcuts', () => {
const hits = searchSettings('Volume', 'library', t as any);
const hits = searchSettings('Volume', 'library', t as unknown as TFunction);
expect(hits.some(h => h.title === 'Volume up')).toBe(true);
expect(hits.some(h => h.title === 'Volume down')).toBe(true);
});
it('returns nothing for nonsense queries', () => {
expect(searchSettings('aaaaaaa', 'library', t as any)).toEqual([]);
expect(searchSettings('aaaaaaa', 'library', t as unknown as TFunction)).toEqual([]);
});
});
+2 -2
View File
@@ -17,11 +17,11 @@ export function searchSettings(query: string, activeTab: Tab, t: TFunction): Set
const hits: SettingsSearchHit[] = [];
for (const entry of SETTINGS_INDEX) {
const title = t(entry.titleKey as any);
const title = t(entry.titleKey);
const hay = entry.keywords ? `${title} ${entry.keywords}` : title;
const score = matchScore(hay, q);
if (score <= 0) continue;
const focusTitle = entry.focusTitleKey ? t(entry.focusTitleKey as any) : title;
const focusTitle = entry.focusTitleKey ? t(entry.focusTitleKey) : title;
hits.push({ tab: entry.tab, title, focusTitle, score, key: entry.titleKey });
}
@@ -69,8 +69,14 @@ export default function SidebarPerfProbeLogsTab() {
// Topmost visible line to re-pin against while the user is scrolled up, so the
// view stays put even as new lines append below or old ones scroll out.
const anchorRef = useRef<{ seq: number; offset: number } | null>(null);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
pausedRef.current = paused;
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
lineCapRef.current = lineCap;
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
followRef.current = follow;
// Keep the backend mode readout in sync with reality on open.
@@ -130,6 +136,8 @@ export default function SidebarPerfProbeLogsTab() {
// When following resumes (or the cap shrinks), trim retained history to the cap.
useEffect(() => {
if (!follow) return;
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLines(prev => (prev.length > lineCap ? prev.slice(prev.length - lineCap) : prev));
}, [follow, lineCap]);

Some files were not shown because too many files have changed in this diff Show More