mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
|
||||
"npmDepsHash": "sha256-g18089KGJ4RdCag14vNLKpa048rSm1Z5XzEMVUBJKac="
|
||||
}
|
||||
|
||||
Generated
+1619
-24
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 row’s identity changes (new data / server), not when the list grows via
|
||||
// “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders.
|
||||
const rowArtworkResetKey = uniqueAlbums[0]?.id ?? '';
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s 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(() => {
|
||||
|
||||
@@ -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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 effect’s 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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
setRefreshing(false);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
setSearchSource(null);
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 effect’s 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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 effect’s 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
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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 effect’s 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 && (
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}%)`,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 effect’s 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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||
|
||||
if (failed) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBlockPasswordReveal(true);
|
||||
setForm(f => ({
|
||||
...f,
|
||||
|
||||
@@ -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 effect’s 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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isPsyDragging]);
|
||||
|
||||
|
||||
@@ -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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isPsyDragging]);
|
||||
|
||||
|
||||
@@ -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 effect’s 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 effect’s 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 effect’s 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 effect’s 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));
|
||||
|
||||
@@ -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 effect’s 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 effect’s 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user