Add cardGridLayout helpers, useCardGridMetrics, useRemeasureGridVirtualizer, and VirtualCardGrid (TanStack row virtualization, always remeasure on layout changes).
Apply to Artists (grid + list unchanged policy), Albums, Composers grid, playlists, radio stations, offline library, and album-heavy browse/detail pages. Respect disableMainstageVirtualLists for a non-virtual grid with the same column rules.
Includes vitest coverage for column cap.
* fix(ui): use stable list keys on Now Playing dashboard cards
Subsonic payloads can repeat the same id in similar artists, album
track rows, and top songs. Keys now combine id with list index so
React reconciliation stays stable and duplicate-key warnings stop.
* docs: changelog and credits for Now Playing list keys (PR #703)
Document the dashboard list key fix in CHANGELOG and Settings contributors.
The delete button referenced the CSS classes `playlist-card-delete` /
`playlist-card-delete--confirm`, which were renamed to
`playlist-card-action--delete` / `--delete-confirm` long ago when
PlaylistCard was reworked. InternetRadio was missed, so the button
rendered unstyled and effectively invisible.
Wrap it in `.playlist-card-actions` and use the current classes,
matching PlaylistCard — no new CSS needed.
* fix(ui): split OpenSubsonic album and track artists in header and player
Album detail header uses albumArtists from album or child songs; player bar,
mobile player, and mini player use structured track artists with per-id links.
Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline.
Fixes#552
* docs: changelog and credits for OpenSubsonic artist links (PR #696)
* fix(player): align cached cover URL with cacheKey on track change
Prevents a one-frame stale blob src (and broken image in the player bar)
when switching tracks; reset CachedImage load state in useLayoutEffect.
* docs: changelog + credits for cover-art track-switch fix (PR #695)
Findings 5-8 of the dedup audit:
- F5 byte formatters: appUpdaterHelpers.fmtBytes + ZipDownloadOverlay
.formatMB route through the existing formatBytes; a new formatMb
(always-MB) backs playlistDetailHelpers.formatSize, AlbumHeader and
the 4 inline DeviceSyncPreSyncModal expressions. SongInfoModal.format
Size is intentionally left — it uses decimal (1e6) divisors, not 1024.
- F6 sanitizeHtml: extracted to utils/sanitizeHtml.ts; AlbumHeader,
ComposerDetail and the (now-empty, deleted) artistDetailHelpers use it
directly. nowPlayingHelpers keeps its own export but now delegates to
the shared sanitiser and only adds its trailing-link strip on top.
- F7 album duration: BecauseYouLikeRail's formatAlbumDuration drops in
favour of the shared formatHumanHoursMinutes. Behaviour note: total
minutes now floor instead of round (<=1 min display difference,
matches every other caller).
- F8 clock time: extracted to utils/format/formatClockTime.ts;
PlaybackDelayModal + QueueHeader use it (toLocaleTimeString and
Intl.DateTimeFormat produced identical output).
Behaviour preserved except the two explicitly noted divergences (F7
round->floor; F5 appUpdater/Zip now show GB above 1 GB instead of a
large MB number).
Findings 3 + 4 of the dedup audit — replace hand-rolled copies with the
utils that already exist:
- shuffleArray (utils/playback/shuffleArray.ts): BecauseYouLikeRail's
local shuffle<T>, plus the inline Fisher-Yates loops in RandomAlbums,
AlbumDetail and Home.
- dedupeById (utils/dedupeById.ts): the identical seen-Set/filter
union-dedupe block in the fetchByGenres of Albums, NewReleases and
RandomAlbums.
The extracted utils are character-identical to the inline loops, so no
behaviour change. RandomMix's biased `.sort(() => Math.random() - 0.5)`
is intentionally left alone — swapping it would change behaviour.
The mm:ss track-time formatter was hand-rolled in 11 places and the
h:mm:ss total-duration formatter in 4 — extract two tested functions:
- formatTrackTime(seconds, fallback='0:00') — m:ss, used for track /
playback times. fallback param covers the '–' placeholder rows.
- formatLongDuration(seconds) — h:mm:ss when >=1h, else
m:ss, used for album / queue totals.
Behaviour preserved per call site: the unified guard
(!seconds || !isFinite || <0 -> fallback) produces identical output to
every prior variant for all real inputs; SongRow keeps its '–' via the
fallback arg. Removes the formatter exports from 6 componentHelpers
files (playerBarHelpers / fullscreenPlayerHelpers deleted — they only
exported the formatter) and 7 inline component copies.
+ formatDuration.test.ts
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.
Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
Extract the GitHub release probe, download/relaunch state and the markdown
changelog renderer out of AppUpdater.tsx:
- utils/appUpdaterHelpers.ts isNewer, fmtBytes, pickAsset, types
- hooks/useAppUpdater.ts release probe + download/relaunch handlers
- components/appUpdater/Changelog.tsx
Pure code-move, no behaviour change. The AppShell call site is unchanged.
Extract the canvas frequency-response math, the custom vertical fader, the
AutoEQ parser, and the AutoEQ search panel out of Equalizer.tsx:
- utils/eqCurve.ts biquadPeakResponse + drawCurve
- utils/autoEqParse.ts AutoEq types + parseFixedBandEqString
- components/equalizer/VerticalFader.tsx
- hooks/useAutoEq.ts AutoEQ search/apply state
- components/equalizer/AutoEqSection.tsx
Pure code-move, no behaviour change. Both call sites (AudioTab, PlayerBar)
default-import Equalizer unchanged.
* refactor(album-detail): extract sanitizeFilename + useAlbumDetailData
Move sanitizeFilename into utils/albumDetailHelpers.ts. Pull the album +
related-albums fetch and the starred state seeds (isStarred,
starredSongs) into hooks/useAlbumDetailData.ts.
AlbumDetail.tsx: 511 → 483 LOC.
* refactor(album-detail): extract useAlbumOfflineState hook
Move the four primitive-selector offline status reads (cache map +
job-status filters + progress totals) into hooks/useAlbumOfflineState.ts.
Keeps the re-render minimisation comment with the code that needs it.
AlbumDetail.tsx: 483 → 461 LOC.
* refactor(album-detail): extract useAlbumDetailSort hook
Pull sortKey/Dir/clickCount state, the 3-click natural-reset cycle in
handleSort, and the displayedSongs memo (filter + sort comparator) into
hooks/useAlbumDetailSort.ts. Rating comparator keeps the same priority
chain as the row renderer.
AlbumDetail.tsx: 461 → 414 LOC.
* refactor(album-detail): extract AlbumDetailToolbar subcomponent
Pull the search input + bulk-action cluster (selection count, add-to-
playlist popover, clear-selection button) into
components/albumDetail/AlbumDetailToolbar.tsx. Parent retains showPlPicker
to coordinate the popover close with selection clears.
AlbumDetail.tsx: 414 → 369 LOC.
* refactor(user-mgmt): extract formatLastSeen helper
Move the relative-time formatter (with the Navidrome
'0001-01-01T00:00:00Z' epoch guard) into utils/userMgmtHelpers.ts.
UserManagementSection.tsx: 515 → 499 LOC.
* refactor(user-mgmt): extract useUserMgmtData hook
Pull users + libraries state, sequential admin-API fetch, and the
nginx-friendly error normalisation into hooks/useUserMgmtData.ts.
UserManagementSection.tsx: 499 → 464 LOC.
* refactor(user-mgmt): extract useUserMgmtActions hook
Bundle handleSave (covers create + edit + library assignment),
handleSaveAndGetMagic (new non-admin user → encoded magic string on
clipboard), and performDelete into hooks/useUserMgmtActions.ts. The
delete confirmation modal now closes inline in the parent before
delegating to performDelete so the hook stays agnostic of UI state.
UserManagementSection.tsx: 464 → 343 LOC.
* refactor(user-mgmt): extract UserMgmtRow subcomponent
Move the per-user list row (user/admin badges, lib-names blob, magic-
string + delete actions, keyboard activation) into
components/settings/userMgmt/UserMgmtRow.tsx.
UserManagementSection.tsx: 343 → 272 LOC.
* refactor(user-mgmt): extract MagicStringModal subcomponent
Move the per-user magic-string portal modal (password re-set + clipboard
copy of the encoded server-magic-string) into
components/settings/userMgmt/MagicStringModal.tsx. Internal password and
submitting state move into the modal; the parent only owns which user is
targeted.
UserManagementSection.tsx: 272 → 153 LOC.
* refactor(artists): extract helpers + constants
Pull ALL_SENTINEL / ALPHABET / ARTIST_LIST_* row-height estimates,
the ArtistListFlatRow union, CTP_COLORS palette, and the deterministic
nameColor / nameInitial helpers into utils/artistsHelpers.ts.
Artists.tsx: 520 → 496 LOC.
* refactor(artists): extract ArtistAvatars subcomponents
Pull ArtistCardAvatar (300px, grid view) and ArtistRowAvatar (64px, list
view) into components/artists/ArtistAvatars.tsx. Both fall back to a
hashed-Catppuccin monogram when artist images are off or no cover art
is available.
Artists.tsx: 496 → 436 LOC.
* refactor(artists): extract useArtistsFiltering hook
Bundle the letter/text/star filter pipeline, visible-slice memo,
group-by-letter, and the virtualizer flat-rows list into
hooks/useArtistsFiltering.ts. List-view-only outputs short-circuit when
grid view is active.
Artists.tsx: 436 → 386 LOC.
* refactor(artists): extract useArtistsInfiniteScroll hook
Bundle visibleCount + loadingMore state, the sentinel
IntersectionObserver, loadMore callback, and the filter-change reset
into hooks/useArtistsInfiniteScroll.ts. The observer no longer takes
hasMore — the sentinel element only mounts while there is more data,
so the observer attaches/detaches naturally with it.
Artists.tsx: 386 → 370 LOC.
* refactor(artists): extract ArtistsGridView + ArtistsListView
Move the grid card layout to components/artists/ArtistsGridView.tsx and
the dual-path list layout (non-virtualized fallback + virtualized stream)
to components/artists/ArtistsListView.tsx. Both paths now share an
internal ArtistListRow component so click + context-menu behaviour is
identical regardless of which renderer is active.
Artists.tsx: 370 → 233 LOC.
* refactor(album-track-list): extract helpers + types
Pull formatDuration / codecLabel, the COLUMNS / CENTERED_COLS / SORTABLE_COLS
tables, ColKey / SortKey types, and the isSortable type guard into
utils/albumTrackListHelpers.ts. SortKey is re-exported from
AlbumTrackList.tsx so existing imports stay valid.
AlbumTrackList.tsx: 662 → 633 LOC.
* refactor(album-track-list): extract TrackRow subcomponent
Move the memoised tracklist row (~220 LOC including renderCell switch and
mouse handlers) into components/albumTrackList/TrackRow.tsx. It still
subscribes to its own selection + preview state via primitive selectors,
so per-row re-render scope is unchanged.
AlbumTrackList.tsx: 633 → 404 LOC.
* refactor(album-track-list): extract AlbumTrackListMobile subcomponent
Move the narrow-viewport branch (compact tracklist with disc separators
and no column grid) into components/albumTrackList/AlbumTrackListMobile.tsx.
AlbumTrackList.tsx: 404 → 376 LOC.
* refactor(album-track-list): extract TracklistColumnPicker subcomponent
The column visibility dropdown lives outside .tracklist to avoid the
overflow box clipping its menu — pull the wrapper + button + popover into
components/albumTrackList/TracklistColumnPicker.tsx.
AlbumTrackList.tsx: 376 → 347 LOC.
* refactor(album-track-list): extract TracklistHeaderRow subcomponent
The fixed header (sortable + resizable per-column with the bulk-select
toggle on the num cell) moves into
components/albumTrackList/TracklistHeaderRow.tsx, taking 85+ LOC of cell
rendering with it.
AlbumTrackList.tsx: 347 → 254 LOC.
* refactor(album-track-list): extract useAlbumTrackListSelection hook
Pull bulk-selection state (selectedIds-size subscription, shift-range
toggle, click-outside-clear, song-list-change clear) and the drag-start
dispatcher (single vs multi-song drag) into
hooks/useAlbumTrackListSelection.ts.
AlbumTrackList.tsx: 254 → 187 LOC.
* refactor(player-bar): H7 — extract PlaybackTime + RemainingTime + formatTime
The two memoized clock components (which update the DOM imperatively from
the playbackProgress store without re-rendering PlayerBar) move into
components/playerBar/PlaybackClock.tsx. formatTime helper → utils/playerBarHelpers.ts.
PlayerBar.tsx: 802 → 765 LOC.
* refactor(player-bar): H7 — extract PlayerTrackInfo
The cover-art wrap + title/artist marquees + star + last.fm love buttons
move into their own component. The new file uses PlayerState['openContextMenu']
for prop typing so the union literal type carries through.
PlayerBar.tsx: 765 → 674 LOC.
* refactor(player-bar): H7 — extract PlayerTransportControls
Stop/Prev/Play/Next/Repeat buttons (with the preview-ring + schedule-badge
overlays around play/pause) move into PlayerTransportControls.tsx. The
component uses ReturnType<...> on the source hooks to derive its
playPauseBind + scheduleRemaining prop types so the new file stays in lockstep
with usePlaybackDelayPress + usePlaybackScheduleRemaining.
PlayerBar.tsx: 674 → 621 LOC.
* refactor(player-bar): H7 — extract PlayerSeekbarSection
The waveform / radio progress / time-label block moves into its own
component. PlayerSeekbarSection branches on isRadio (AzuraCast progress
bar with elapsed+duration when available; LIVE badge otherwise) vs.
regular track (WaveformSeek or perf-flag fallback + duration ↔ remaining
toggle).
PlayerBar.tsx: 621 → 582 LOC.
* refactor(player-bar): H7 — extract PlayerVolume + PlayerOverflowMenu + 2 hooks
PlayerVolume.tsx is the reusable volume button + slider combo, used in
three layouts (inline, full menu, volume-only menu) — `inputId` /
`sectionModifier` / `wrapModifier` props handle the variants without
class duplication. PlayerOverflowMenu.tsx is the portaled Ellipsis-button
menu that hosts EQ / mini-player buttons + a PlayerVolume instance.
useFloatingPlayerBar owns the docked/floating layout computation
(ResizeObserver on sidebar + queue panel). useUtilityOverflowMenu owns
the overflow detection, menu open/mode state, close-on-outside-click /
Escape, position recompute on resize/scroll, and the wheel-menu timer.
PlayerBar.tsx: 582 → 354 LOC.
* refactor(fullscreen-player): H5 — extract FsLyricsApple + FsLyricsRail + useWordLyricsSync
The two lyrics views become own files under components/fullscreenPlayer/.
Their identical word-sync imperative DOM-update useEffect (only differing in
the .fsa-/.fsr- class prefix) collapses into the shared useWordLyricsSync
hook with a classPrefix arg.
FullscreenPlayer.tsx: 911 → 613 LOC.
* refactor(fullscreen-player): H5 — extract FsArt + FsPortrait + FsSeekbar
The three visual subcomponents move into own files. formatTime moves into
utils/fullscreenPlayerHelpers.ts so FsSeekbar keeps using it.
FullscreenPlayer.tsx: 613 → 421 LOC.
* refactor(fullscreen-player): H5 — extract FsLyricsMenu + FsPlayBtn
The lyrics-settings popover and the isolated play/pause button move into
own files. FullscreenPlayer drops the now-unused lucide-react icons
(Play/Pause/Moon/Sunrise/Music) and the PlaybackDelayModal +
PlaybackScheduleBadge imports — both used only by FsPlayBtn now.
FullscreenPlayer.tsx: 421 → 304 LOC.
* refactor(fullscreen-player): H5 — extract useFsDynamicAccent + useFsArtistPortrait + useFsIdleFade
Pulls three state+effect islands out of FullscreenPlayer:
- useFsDynamicAccent owns the cover-blob fetch + extractCoverColors call
plus the module-level artKey → accent cache that makes same-album song
switches instant.
- useFsArtistPortrait fetches getArtistInfo().largeImageUrl for the right-
side portrait, returning '' until resolved (or when no artistId).
- useFsIdleFade flips isIdle true after 3 s of inactivity, exposes a
throttled mousemove handler, and binds Escape to the provided callback.
FullscreenPlayer.tsx: 304 → 228 LOC.
* refactor(queue-panel): H4 — extract helpers + Save/LoadPlaylistModal
Pure code-move: formatTime, formatQueueReplayGainParts, renderStars and the
DurationMode type → utils/queuePanelHelpers.tsx; the two playlist modals → own
files under components/queuePanel/.
QueuePanel.tsx: 1256 → 1104 LOC.
* refactor(queue-panel): H4 — extract QueueHeader
Pure code-move: the title/count/duration/collapse-button header → its own
component file. No prop or behaviour changes.
QueuePanel.tsx: 1104 → 1004 LOC.
* refactor(queue-panel): H4 — extract QueueCurrentTrack + QueueLufsTargetMenu
The currently-playing track block (cover, info, replay-gain / LUFS badge with
its target-listbox portal) moves into two own files. Pure code-move via prop
plumbing. setLoudnessTargetLufs is typed as LoudnessLufsPreset throughout the
new components.
QueuePanel.tsx: 1004 → 812 LOC.
* refactor(queue-panel): H4 — extract useQueuePanelDrag hook
Moves the psy-drag wiring (hit-test registration, drop-inside dispatch
for song/songs/album/queue_reorder payloads, drop-outside removal) into
its own hook. Drops the dead isRadioDrag variable since the
parsedData.type === 'radio' guard inside onPsyDrop already handles that
case.
QueuePanel.tsx: 812 → 715 LOC.
* refactor(queue-panel): H4 — extract useQueueLufsTgtPopover hook
Pulls the LUFS-target popover open-state, button/menu refs, fixed-position
recompute on open/resize/scroll, and auto-close-when-RG-collapses out of
QueuePanel.
QueuePanel.tsx: 715 → 662 LOC.
* refactor(queue-panel): H4 — extract QueueToolbar
The toolbar-button switch (shuffle/save/load/share/clear/gapless/crossfade/
infinite) and the crossfade popover (with its close-on-outside-click effect)
move into one component. crossfadeBtnRef / crossfadePopoverRef and
showCrossfadePopover state are now component-local — QueuePanel no longer
sees them.
QueuePanel.tsx: 662 → 541 LOC.
* refactor(queue-panel): H4 — extract QueueList
The OverlayScrollArea + queue.map block (with track rows, lucky-mix dice
overlay, and radio/auto-added section dividers) moves into its own
component. PlayerState['contextMenu'] + PlayerState['playTrack'] are
re-used for prop typing, the local StartDrag alias matches the
DragDropContext signature.
QueuePanel.tsx: 541 → 434 LOC.
* refactor(queue-panel): H4 — extract QueueTabBar + useQueueAutoScroll, final cleanup
QueueTabBar is the bottom queue/lyrics/info tab switcher. useQueueAutoScroll
groups the three list-scroll effects (publish scrollTop reader, restore
pending snapshot, scroll next track into view on advance). Drops the dead
toggleQueue and replayGainMode selectors plus the now-unused Play, Radio,
MicVocal, ListMusic, Info imports and OverlayScrollArea.
QueuePanel.tsx: 434 → 383 LOC. Every new file under 400.
* feat(i18n): Add Romanian translation
* feat(i18n): Update Romanian translation to lang file changes
* feat(i18n): Add new Romanian translation entries
* fix(i18n): add settings.languageRo to remaining locale bundles
Romanian was missing from the language picker labels when UI was not en/ro;
add endonym-style names per locale (de/fr/nl/nb/ru/es/zh) for consistency.
* fix(i18n): use Romanian autonym for settings.languageRo everywhere
Match existing language picker convention (e.g. languageDe is Deutsch in
every locale bundle). Replaces UI-language translations of Romanian.
* refactor(internet-radio): G.86.1 — extract RadioToolbar + AlphabetFilterBar
First cut on InternetRadio.tsx: pulled the two header bars into
their own files under src/components/internetRadio/. RadioToolbar
exports the RadioSortBy type alias so the page state and the
toolbar share the same union. AlphabetFilterBar owns the
A-Z + # key list internally.
Pure code move.
* refactor(internet-radio): G.86.2 — extract RadioCard
Pulled the single radio-station card component (cover, live overlay,
play/delete buttons, name + edit/favourite/homepage chip row) into
its own file. It owns its drag source + the psy-drop listener that
fires onDropOnto with the cursor-side (before/after).
Pure code move.
* refactor(internet-radio): G.86.3 — extract RadioEditModal
Pulled the create/edit-station modal (cover preview + change/remove,
name + stream URL + homepage URL fields, save spinner) into its own
file. station=null means "create new". Pure code move.
* refactor(internet-radio): G.86.4 — extract RadioDirectoryModal
Pulled the radio-browser directory modal (top-stations preload,
debounced search, IntersectionObserver-driven pagination, favicon
+ add-station flow with cover upload from favicon URL) into its
own file. Pure code move.
InternetRadio.tsx is now 299 LOC — every subcomponent lives in
src/components/internetRadio/.
Four-cut cluster closing out the major ArtistDetail extraction.
707 → 339 LOC (−368).
ArtistDetailHero — the full hero header: back button, lightbox
trigger, avatar with hover upload overlay + camera/loader icon +
hidden file input, glow effect from extractCoverColors onLoad,
title + album count, entity-rating row, Last.fm + Wikipedia +
favourite link row, and the action button strip (play all,
shuffle, radio, share, offline cache with progress / done state).
Subscribes to useOfflineStore / useOfflineJobStore / useAuthStore
directly so the page doesn't have to thread bulk-progress through.
ArtistDetailTopTracks — the four-column tracklist with each row's
inline play-next + preview ring + track cover thumbnail + click
into playTopSongWithContinuation. Subscribes to playerStore /
previewStore / useOrbitSongRowBehavior directly.
ArtistDetailSimilarArtists — section header (with show-more toggle
on mobile), loading spinner, and the chip list (using
serverSimilarArtists vs. similarArtists depending on which path
fed it).
runArtistDetailActions — four parameterized async actions:
runArtistEntityRating (with full / track_only fallback +
saveFailed toast), runArtistToggleStar (optimistic state + revert
on error), runArtistShare (copy link + success / failure toast),
runArtistImageUpload (upload + invalidate cover-art cache + bump
revision).
ArtistDetail drops the inline definitions; formatDuration moves
into the TopTracks component. Pure code move otherwise.
Five-cut cluster opening the ArtistDetail refactor. 944 → 631 LOC
(−313).
artistDetailHelpers — formatDuration (M:SS) + sanitizeHtml (strip
script/style/iframe/etc tags + onXxx + javascript: / data: hrefs).
ArtistSuggestionTrackCover — tiny CachedImage wrapper for the
32×32 cover thumbnail in the suggestions tracklist.
useArtistDetailData — owns the page's three primary fetch effects:
getArtist + getTopSongs on artist id change, getArtistInfo on id +
audiomuseNavidromeEnabled change, and the background "Also Featured
On" search that derives albums from search results not in the
artist's own album set. Exposes artist / setArtist / albums /
topSongs / info / featuredAlbums / loading flags + isStarred for
the star button.
useArtistSimilarArtists — owns the two parallel similar-artist
effects (Last.fm primary path when AudioMuse is off; Last.fm
fallback when AudioMuse is on but returned nothing) plus the
audiomuse-positive-result reset. Returns { similarArtists,
similarLoading }.
runArtistDetailPlay — three play orchestrators that share the
fetchAllTracks helper: runArtistDetailPlayAll, runArtistDetailShuffle,
runArtistDetailStartRadio (with the no-radio fallback alert). All
take deps objects so the page just delegates state setters.
ArtistDetail drops the now-unused direct search / getArtistInfo /
getTopSongs / getSimilarSongs2 / getArtist / lastfmGetSimilarArtists
imports. Pure code move otherwise.
Three-cut cluster pulling the dominant songs section out of
Favorites.tsx. 645 → 217 LOC (−428).
FavoritesSongsSectionHeader — the section above the tracklist:
title with showing-N-of-M indicator, Play-All / Enqueue-All
buttons, filter toggle, clear-all button (resets artist + genre +
year + sort), filters panel with GenreFilterBar + dual-range
year sliders, and the "clear artist filter" button when an
artist filter is active. Takes the minYear / currentYear
constants explicitly so the page still owns them.
FavoritesSongsTracklist — the tracklist below: bulk-action bar
(N selected + Add-to-playlist submenu + clear), column-visibility
picker, sortable column header, song rows (selection check + bulk
toggle, currentTrack highlight, inline play + preview buttons in
the title cell, artist/album link cells, genre/format/duration/
rating cells, remove button), and the no-filter-results empty
state. Subscribes to playerStore / previewStore / selectionStore /
useDragDrop / useOrbitSongRowBehavior directly.
useFavoritesSelection — owns lastSelectedIdxRef and the two
useEffects (clear-on-songs-change + clear-on-click-outside) plus
the toggleSelect callback with shift-range support.
Favorites drops the inline definitions and removes the now-unused
direct useRef / useCallback declarations. Pure code move
otherwise.
Four-cut cluster opening the Favorites refactor. 1018 → 643 LOC
(−375).
TopFavoriteArtists — TopFavoriteArtistsRow (the horizontal-scroll
section with chevron nav buttons and resize-driven scroll-state)
plus the private TopFavoriteArtistCard with the cached avatar
image and selected-outline styling. Exports the
TopFavoriteArtist row-data shape.
RadioFavorites — RadioStationRow (same horizontal-scroll pattern
as the artists row) plus the private RadioFavCard with cover or
Cast-icon fallback, live-radio badge overlay when active, and an
unfavorite heart button.
useFavoritesData — owns the four data states (albums, artists,
songs, radioStations) + loading + the load-on-mount effect (calls
getStarred + reads radio favorites from localStorage + fetches
matching stations). Computes topFavoriteArtists memo (counts
favorited songs by artist, top 12). Exports unfavoriteStation
(removes from state + persists to localStorage).
useFavoritesSongFiltering — owns the filtering pipeline (drops
unfavorited, applies artist / genre / year-range filters) and
the three-state sort (asc → desc → reset). Returns
filteredSongs / visibleSongs plus handleSortClick /
getSortIndicator. Hook file uses .tsx because getSortIndicator
returns ArrowUp / ArrowDown JSX.
Favorites drops the inline definitions plus the now-unused direct
imports (getInternetRadioStations, getStarred, buildCoverArtUrl /
coverArtCacheKey, useAuthStore, Users / ArrowUp / ArrowDown
icons). Pure code move otherwise.
Three-cut cluster closing out the Playlists refactor. 516 → 273 LOC
(−243).
runPlaylistsActions — runPlaylistDelete (two-click confirm with
tooltip re-trigger), runPlaylistDeleteSelected (filters by
deletable, refreshes store, fires per-row error toasts), and
runPlaylistMergeSelected (collects unique songs across selected
playlists into the target, updatePlaylist + touchPlaylist + total
count toast). Each takes a deps object so all state-setters /
callbacks are explicit.
PlaylistsHeader — title row + creation controls (inline name
input with Enter / Escape handling, "New playlist" button,
"New smart" button gated on isNavidromeServer) + bulk delete
button + selection-mode toggle. The selection-mode title swaps
between t('playlists.title') and t('playlists.selectionCount').
PlaylistCard — full single-card render: cover area (smart-playlist
2×2 collage / cover image / fallback ListMusic icon + pending
clock badge), hover-only edit + delete buttons (delete with
two-click confirm), selection check overlay, play overlay button
with spinner state, and the info row (smart-playlist sparkle +
display name + song count + duration). Subscribes to
playerStore.openContextMenu directly.
Playlists drops the inline definitions + the now-unused direct
imports (deletePlaylist / updatePlaylist, buildCoverArtUrl /
coverArtCacheKey, CachedImage, StarRating, the cover image
helpers, most lucide icons, useMemo). Pure code move otherwise.
Four-cut cluster pulling the Smart-playlist machinery out of
Playlists.tsx. 763 → 480 LOC (−283).
runPlaylistsOpenSmartEditor — open-existing flow: tries
ndGetSmartPlaylist first (freshest rules), falls back to
ndListSmartPlaylists if that fails or doesn't return the playlist;
populates the editor with parsed filters or a name-only seed for
shared / migrated edge cases; degrades gracefully with a warning
toast if everything fails.
runPlaylistsSaveSmart — create / update flow: dedupes the base
name against existing playlists by appending `-2`, `-3` … on
creation (skipped on edit); builds rules via
buildSmartRulesPayload; calls ndCreate or ndUpdate; tracks the
result in pendingSmart so the polling hook can observe rules
processing on the server.
usePendingSmartPolling — every 10 s polls fetchPlaylists +
getPlaylist for each pending item; rehydrates the playlist store
when the detail endpoint reports fresh metadata before the list
endpoint catches up; stops polling an item when it has songs +
its cover changed (or after ~3 minutes hard timeout).
PlaylistsSmartEditor — the full smart-editor card (three
sections: Basic / Genres / Years + Filters). Owns no state of
its own; every input is a controlled component against
smartFilters via setSmartFilters. The cancel button still resets
through the page's setters.
Playlists drops the inline definitions plus its direct
'../api/navidromeSmart' import (now consumed inside the two
orchestrators). Pure code move otherwise.
Four-cut cluster opening the Playlists refactor. 1039 → 763 LOC
(−276).
playlistsSmart — full smart-playlist module: SMART_PREFIX /
LIMIT_MAX / YEAR_MIN / YEAR_MAX constants, GenreMode / YearMode /
SmartFilters / PendingSmartPlaylist / NdSmartRuleNode types,
defaultSmartFilters seed, clampYear / isSmartPlaylistName /
displayPlaylistName / asRecord helpers, parseSmartRulesToFilters
(the Navidrome JSON rule walker), and buildSmartRulesPayload (the
reverse — page-state → Navidrome JSON). The payload builder
becomes parameterized on the filters object so it lives outside
the component.
PlaylistCoverImages — two tiny CachedImage wrappers
(PlaylistSmartCoverCell for the 200 px collage cells,
PlaylistCardMainCover for the 256 px main card cover).
useSmartCoverCollage — replaces the inline useEffect that builds
the 2×2 cover collage for each smart playlist (pulls playlist
tracks, filters to active library scope, collects up to four
unique cover-art ids). Returns the per-playlist id map and
re-fetches on playlist list change or library filter version bump.
usePlaylistsLibraryScopeCounts — replaces the inline useEffect
that recomputes per-playlist song count + total duration under
the current library scope. Chunked into batches of four parallel
fetches.
Playlists drops the inline definitions; pure code move otherwise.
Two-cut cluster pulling the main layout columns out of
DeviceSync.tsx. 511 → 233 LOC (−278). DeviceSync is now mostly
glue: state hooks, hook calls, action wrappers, and a flat tree of
five layout components.
DeviceSyncBrowserPanel — left column. Owns the tabs row
(playlists / albums / artists with icons), the search input with
the "Live search" badge on the albums tab, and the result list:
loading spinner, "Random albums" section label, playlist /
album / artist rows with their BrowserRow leaf component, and the
expand-an-artist tree (loading state, chevron, child album rows
with indent). filteredPlaylists / filteredArtists memos move into
the panel since only the row mapping consumes them.
DeviceSyncDevicePanel — right column. Owns the header (title +
scanning spinner + sync action button with three label variants
+ "Delete from device" button), the status badges row
(synced / pending / deletion), the source list with checkbox /
type / status icon / per-row action (mark-for-deletion /
remove-source / undo-deletion), and the bottom progress strip
(running / cancelled / done) with their dismiss / cancel
buttons. invoke('cancel_device_sync') stays in the panel since
it's a panel-local action.
DeviceSync drops the now-unused invoke / BrowserRow / useMemo
imports (filteredPlaylists/Artists moved into the panel). Pure
code move otherwise.
Three-cut cluster pulling the chrome out of DeviceSync.tsx. 739 →
501 LOC (−238).
DeviceSyncHeader — title row, fixed-scheme info block (with the
"Reorganize existing files…" migrate button), and the drive picker
row (manual folder picker, refresh, CustomSelect over detected
drives or no-drives fallback, drive metadata line).
DeviceSyncPreSyncModal — the modal that opens before sync execution:
loading spinner while calculate_sync_payload runs, then the
delta-stats grid (add count + bytes, delete count + bytes, net
change, available space) with the space-warning when add exceeds
available + del, plus the cancel / proceed footer.
DeviceSyncMigrationModal — the migrate-existing-files modal with
its five-phase state machine (loading / nothing / preview /
executing / done): preview lists rename count + unchanged count
+ collision warning + old-template note; done shows ok / failed
counts + a collapsible error list capped at 50 entries.
DeviceSync drops the inline JSX + the now-unused HardDriveUpload /
FolderOpen / Usb / RefreshCw / Loader2 (partially) icon imports
that only the header used. Pure code move otherwise.
Four-cut cluster opening the DeviceSync refactor. 1289 → 1186 LOC
(−103).
deviceSyncHelpers — uuid, formatBytes, trackToSyncInfo (with the
albumArtist-fallback-to-artist logic and the optional playlist
context the Rust sync command consumes), plus the SourceTab /
SyncStatus / RemovableDrive / SyncTrackMaybePlaylist types.
deviceSyncLegacyTemplate — sanitizeComponent (matches Rust's
sanitize_path_component) + OldTemplateTrack + applyLegacyTemplate.
Lives apart from the general helpers because it's only used by the
migration-preview flow and pulls in IS_WINDOWS for path separator.
fetchTracksForSource — single async helper that loads the songs for
a DeviceSyncSource (playlist / album / artist). Artist sources fan
out into parallel getAlbum requests (Navidrome handles them
concurrently; the old sequential loop was a ~7 s blocker on
50-album artists).
BrowserRow — small button row component used by the source-picker
list (playlists / albums / artists tabs).
DeviceSync drops the inline definitions plus the direct getAlbum /
getPlaylist / getArtist imports that only fetchTracksForSource
needed. Pure code move — no behaviour change.
Four-cut cluster closing out the NowPlaying decomposition. 715 → 391
LOC (−324).
TourCard — Bandsintown tour-dates card with the privacy-prompt
gate (shown before the user opts in), loading state, empty state,
5-item show-more list with date / venue / place, and the
"Tour data via Bandsintown" credit footer.
DiscographyCard — chronological album grid (10×2 = 20 tiles
initial, show-more for the rest), each tile a cached cover-art
thumbnail with a tooltip and click-through to the album page.
useNowPlayingFetchers — the eight cached fetch effects that drove
the page: song meta / artist info / album / top songs / Bandsintown
tour events (+ loading state) / discography / Last.fm track stats /
Last.fm artist stats. Each effect follows the same pattern
(cache hit → seed state, cache miss → fetch + setCache + setState,
cancellation flag on cleanup). All eight makeCache<…> instances
move into the hook module too.
useNowPlayingStarLove — local starred + lfmLoved booleans seeded
from songMeta.starred and lfmTrack.userLoved respectively, plus
toggleStar (star/unstar Subsonic API) and toggleLfmLove
(lastfmLove/Unlove with the session key).
NowPlaying drops the now-unused imports: star/unstar (Subsonic),
getArtist/getArtistInfo/getTopSongs/getSong/getAlbum, the entire
'../api/lastfm' line, fetchBandsintownEvents + BandsintownEvent,
makeCache (was passing through to the page only for cache
instantiation), plus SubsonicAlbum (only the type is still used
inside the hook). The radio + dashboard JSX in the body still
references everything via the hook returns. Pure code move
otherwise.
Five-cut cluster pulling the dashboard subcards out of NowPlaying.tsx.
1119 → 711 LOC (−408). Each card was already a top-level
React.memo'd block with a clean prop interface, so this is straight
file-per-card extraction with no behaviour change.
Hero — full hero strip: cover image, title, artist/album/year/age
sub, format/codec/sample-rate/bit-depth/duration badges with the
Hi-Res chip, the favourite + Last.fm-love + lyrics action buttons,
play-count line, and the Last.fm track + artist stats rows (with
their per-row `you played N×` highlight). renderStars (5-star
inline indicator) moves inline as a private helper since only Hero
calls it.
ArtistCard — about-this-artist card: optional hero image (filtered
through isRealArtistImage so we don't render the well-known
"2a96…" placeholder), name, sanitized + clamp-with-Read-more bio,
similar-artist chip row. Owns its bioExpanded / bioOverflows state
and the useLayoutEffect that measures overflow.
AlbumCard — from-this-album card: meta line (year / track position /
total duration / play count), sliding-window tracklist anchored on
the current track (top 10 by default, or the 10 ending at the
running track if it's beyond position 10), and the show-all toggle.
TopSongsCard — top-songs-by-artist card: rank-ordered list (max 8),
each row clicks into playerStore via the onPlay callback.
CreditsCard — contributor / song-info card: role-grouped list with
i18n role label lookup (falls back to the raw role string).
NowPlaying drops the now-unused imports (useLayoutEffect, LastfmIcon,
Star, MicVocal, Heart, Headphones, TrendingUp from lucide,
sanitizeHtml + isRealArtistImage + formatTotalDuration from
nowPlayingHelpers) plus the inline renderStars helper. Pure code
move otherwise.
Five-cut cluster opening the NowPlaying refactor. 1384 → 1119 LOC
(−265).
nowPlayingHelpers — eight pure helpers + ContributorRow type:
formatTime, formatCompact, formatTotalDuration, sanitizeHtml
(strip dangerous attributes + trailing Last.fm "Read more" link),
isoToParts (date formatting for Bandsintown), buildContributorRows
(dedupes contributor list, hides redundant "Artist = main artist"
row), isRealArtistImage (filter the Last.fm "2a96…" placeholder MD5
that aggregating Subsonic backends still emit).
nowPlayingCache — module-level TTL cache used by all subcomponents:
CACHE_TTL_MS (5 min), CacheEntry type, makeCache() factory.
NowPlaying still instantiates eight `makeCache<…>()` typed caches
inline at module scope; only the factory + TTL constant move.
NpCardWrap — drag-source wrapper around each dashboard card,
participates in the psyDnD drag stream via useDragSource.
NpColumnEl — drop-target column. Owns the document mousemove
listener that determines which wrapper the dragged card would
land before (x-axis decides column, y-axis bisects wrapper rects
to compute insert index). No-op when no card is being dragged.
RadioView — full radio-playing layout: hero card with stream
name + current artist/title/album + AzuraCast progress bar +
listeners badge, "Up Next" card, recently-played list. Subscribes
to nothing on its own; takes the radioMeta tuple + currentRadio +
resolvedCover from the parent. NonNullStoreField type alias moves
with it.
NowPlaying drops the inline definitions; renderStars + the eight
typed cache instances stay in the page for now (renderStars is used
by Hero + TopSongsCard, both of which still live inline; the
typed caches are consumed by NowPlaying's load effects). Pure code
move otherwise.
Three-cut cluster on PlaylistDetail.tsx. 1065 → 742 LOC (−323).
PlaylistTracklist — the big one. Owns the bulk-action bar, column
visibility picker, sortable header (3-click cycle: asc → desc →
natural, plus arrow indicator + drag-resize handles between
columns), empty state with "add first song" button, and the song
rows themselves (drag-over indicators, current-track highlight,
bulk-selection check, ctrl/meta/shift selection vs. play vs.
orbit-queue-hint, inline play-next + preview buttons in the title
cell, artist/album links, star/rating cells, format/duration/delete
columns). Subscribes directly to playerStore (currentTrack /
isPlaying / playTrack / openContextMenu / starredOverrides /
userRatingOverrides), previewStore (previewingId / audioStarted),
themeStore (showBitrate), useDragDrop (isDragging),
useOrbitSongRowBehavior — so the parent doesn't have to thread any
of that through props. PL_CENTERED moves to the component because
the only remaining tracklist header lives there now.
PlaylistFilterToolbar — small filter input with clear-X.
playlistDisplayedSongs — pure `getDisplayedSongs(songs, opts)` that
returns the filtered + sorted song list. Exports PlaylistSortKey /
PlaylistSortDir types so PlaylistDetail's sortKey/sortDir state and
PlaylistTracklist's props share the same union types.
PlaylistDetail's import list loses the icons/components that only
the tracklist used (AudioLines, ChevronDown, Check, Heart,
RotateCcw, StarRating, AddToPlaylistSubmenu) — they followed the
component to the new file. Pure code move otherwise.
`pages/PlaylistDetail.tsx` 1199 → 1051 LOC. The full hero section
(blurred background, back button, cover with click-to-edit, title
with smart-playlist sparkle, meta line, every action button: play /
shuffle / enqueue / add-songs toggle / CSV import / ZIP download with
inline progress, offline cache toggle with downloading-progress
state) moves to `components/playlist/PlaylistHero.tsx`.
Props cover the data + every callback the buttons fire; the
component subscribes to themeStore for the two cover-art feature
flags (enableCoverArtBackground / enablePlaylistCoverPhoto) and uses
useTranslation + useNavigate directly so the page doesn't pass them
in. Pure code move — same JSX, same handlers, same fragment quirk in
album-detail-meta.
Two-cut cluster on PlaylistDetail.tsx. 1400 → 1199 LOC (−201). Both
JSX islands lift out cleanly — they own no playlist-level state, they
just consume props plus their own store subscriptions.
PlaylistSongSearchPanel — the song-search overlay that opens behind
the "Add songs" button. Owns its render only; query / results /
selection / playlist-picker-open / context-menu-id all stay as state
in PlaylistDetail (the debounced search useEffect still drives them).
The component pulls `openContextMenu` from playerStore directly so
the parent doesn't have to wire it through. PlaylistSearchResultThumb
moves inline with the new file — it has no other consumers.
PlaylistSuggestions — the discover-more strip rendered below the
tracklist. Subscribes to playerStore (openContextMenu + the
play-next inline action), previewStore (previewingId +
audioStarted), themeStore (showBitrate), and react-router
(navigate). Existing-id filter, hovered-id highlight, contextMenuId
and the load-more callback come in as props from the page.
PL_CENTERED is duplicated in the component because the tracklist
header inside PlaylistDetail still references it; dedup is a
follow-up once the tracklist itself is extracted.
PlaylistDetail's import list drops PlaylistSearchResultThumb (now
unused locally) and picks up the two component imports. Pure code
move otherwise — no behaviour change.
Four-cut cluster on PlaylistDetail.tsx. 2274 → 1761 LOC (−513); the page
keeps every behaviour and stateful path, but the leaf helpers, the
Spotify CSV import workflow, and the two stand-alone modals each live
in their own files now.
playlistDetailHelpers — pure helpers: sanitizeFilename, formatDuration,
formatSize, totalDurationLabel, codecLabel, plus SMART_PREFIX with
isSmartPlaylistName / displayPlaylistName. No deps beyond
formatHumanHoursMinutes + SubsonicSong. Kept the duplicates that
already live in ContextMenu / Sidebar / Playlists in place — dedup is
a separate cut, not part of this code-move.
spotifyCsvImport — full Spotify CSV pipeline: HEADER_MAPPINGS,
normalizeHeader, findColumnField, parseArtists, extractFeaturedArtists,
parseSpotifyCsv, plus the SpotifyCsvTrack type re-exported for callers.
papaparse moves with it; PlaylistDetail no longer imports Papa
directly. Header strings keep the \uXXXX escapes so the diff is
byte-identical.
PlaylistEditModal — full edit-meta dialog (name / description / public
toggle / cover swap / cover remove / save spinner). Props match the
old inline component verbatim. Uses React + i18next + CachedImage +
the same lucide icons (Camera, Loader2, X) and SubsonicPlaylist type.
CsvImportReportModal — full import-result dialog (4- or 5-cell stat
grid, duplicate / not-found / network-error lists, download-report
button via Blob + URL.createObjectURL). Still rendered through
createPortal to document.body so the z-index-99999 overlay clears
playlist UI. Imports the SpotifyCsvTrack type from the new CSV module.
PlaylistDetail loses createPortal and Papa from its import list, picks
up two component imports (PlaylistEditModal, CsvImportReportModal), and
the three util imports (playlistDetailHelpers, spotifyCsvImport, the
type-only SpotifyCsvTrack). Pure code move otherwise — no behaviour
change.
Three-tab cluster cut. Settings.tsx 1393 → 345 LOC (−1048); the page
now keeps only the tab header, search UI, route-state handling,
ndAdminAuth probe, and tab dispatch — every section body lives in its
own file.
StorageTab — owns offline dir + cache size readouts + cache-clear flow
+ waveform-cache clear + buffering toggles (preload mode / hot cache
incl. dir picker, sliders, clear) + ZIP downloads dir. The hot-cache
state (imageCacheBytes / offlineCacheBytes / hotCacheBytes /
showClearConfirm / clearing), the hotCacheTrackCount memo, all three
hot-cache useEffects, handleClearCache / handleClearWaveformCache, and
pickOfflineDir / pickHotCacheDir / pickDownloadFolder move with it.
Side effect: the two live hotCacheBytes-refresh useEffects were gated
on `activeTab === 'audio'` (a stale leftover from when hot cache lived
on the audio tab); they now run while StorageTab is mounted, which is
the only place hotCacheBytes is actually displayed.
ServersTab — owns the server list, DnD reorder (psy-drop listener +
drop-target hover state + serverContainerEl + handleServerDragMove),
connStatus map, AddServerForm flow (showAddForm + pastedServerInvite +
addServerInviteAnchorRef + the scroll-into-view useLayoutEffect),
testConnection / switchToServer / deleteServer / handleAddServer /
closeAddServerForm / handleLogout. Settings still owns the route-state
useEffect that catches `openAddServerInvite` and flips to the servers
tab; it passes the invite as `initialInvite` and ServersTab consumes
it on mount + on later prop changes.
SystemTab — owns Language picker, behavior toggles (tray, minimize to
tray, Linux kinetic scroll), Backup section, logging mode + export
runtime logs, About card (maintainers, release notes link,
show-changelog-on-update), Contributors grid, Licenses panel. The
exportRuntimeLogs handler moves with it.
UsersTab stays inline in Settings.tsx — it's an 8-line wrapper around
UserManagementSection gated on ndAdminAuth, which Settings already
owns for the tab-bar visibility check.
The Settings.tsx import list drops 30+ names that only the extracted
tabs used: many lucide icons, openDialog/saveDialog, openUrl, Trans,
showToast, invoke, getImageCacheSize/clearImageCache, usePlayerStore,
useOfflineStore, useHotCacheStore, useDragDrop, pingWithCredentials,
scheduleInstantMixProbeForServer, switchActiveServer, formatBytes,
snapHotCacheMb, MAINTAINERS, CONTRIBUTORS, LicensesPanel,
AboutPsysonicBrandHeader, BackupSection, AddServerForm, ServerGripHandle,
serverListDisplayLabel, showAudiomuseNavidromeServerSetting,
shortHostFromServerUrl, ServerProfile, LoggingMode, LoudnessLufsPreset,
appVersion, i18n, IS_LINUX/IS_WINDOWS, CustomSelect, SettingsSubSection,
plus useMemo/useCallback/useLayoutEffect.
Pure code move otherwise — no behaviour change.
Move the Appearance tab body into AppearanceTab: ThemePicker, theme
scheduler (day/night themes + start times, 24h/12h locale-aware),
visual options card (cover art bg, playlist cover photo, bitrate badge,
floating player bar, artist images, Orbit trigger, preloadMiniPlayer,
custom titlebar on Linux non-tiling), UI scale presets, font picker,
fullscreen player portrait + dim slider, seekbar style picker.
The `isTilingWm` state + `is_tiling_wm_cmd` invoke effect, plus the
`useThemeStore` and `useFontStore` hooks, move into AppearanceTab —
no other tab needs them.
Settings.tsx 1761 → 1404 LOC (−357). Drops 9 imports that only the
appearance tab used (ThemePicker, THEME_GROUPS, SeekbarPreview,
useThemeStore, useFontStore, FontId, SeekbarStyle, lucide Clock /
Maximize2 / Type / ZoomIn).
Pure code move — no behaviour change.