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.
Each src/locales/<lang>.ts (~1800 LOC) becomes a folder src/locales/<lang>/
with one module per i18n namespace (44 each) plus an index.ts barrel that
reassembles <lang>Translation in the original key order.
Mechanical, script-driven split with a JSON round-trip check: every
locale object is byte-identical to its pre-split form. i18n.ts is
unchanged — './locales/<lang>' now resolves to the folder index.
The per-namespace settings.ts files land ~440-460 LOC; a single i18n
namespace is the natural, non-arbitrary split unit for a flat string
table, so they are intentionally left whole.
The prefetch worker / replan orchestration and its shared module state
(pendingQueue, workerRunning, debounceTimer, graceEvictTimer) stay
entirely untouched in hotCachePrefetch.ts. Extracted only what does not
touch that state:
- hotCachePrefetch/helpers.ts pure helpers — entryKey, byte
estimators, debounceMs, frontend
debug log, PREFETCH_AHEAD, PrefetchJob
- hotCachePrefetch/analysisPrune.ts self-contained analysis-queue prune
cluster (its own analysisPruneTimer +
lastAnalysisPruneSig state, fully
encapsulated behind a
resetAnalysisPruneState wrapper)
Verbatim moves. The orchestration core diff is import-block + the three
analysis-cleanup lines collapsing to resetAnalysisPruneState() — nothing
else. The MainApp call site imports initHotCachePrefetch unchanged.
Each listener/subscription concern moves into its own module under
store/audioListenerSetup/; initAudioListeners just composes them in the
original setup / teardown order:
- audioEngineListeners audio:* + analysis:* Tauri listeners
- initialAudioSync one-shot startup sync to the Rust engine
- authSyncListener auth-store + analysis-storage subscriptions
- mprisSync MPRIS / OS media-controls sync
- radioMprisMetadata radio ICY StreamTitle -> MPRIS
- discordPresence Discord Rich Presence sync
Verbatim code-move — listener registration, handler bodies, local state
and the exact cleanup order are all preserved. The MainApp call site and
the three playerStore.*.test.ts suites import initAudioListeners
unchanged.
imageCache.ts keeps the orchestration entry points + re-exports; the
internals move into imageCache/ with an acyclic dependency graph:
- constants.ts DB / cache size knobs
- blobCache.ts in-memory LRU blob map + inflight reads
- urlPool.ts refcounted shared object URLs
- netFetchScheduler.ts priority-ordered network fetch slots
- idbStore.ts IndexedDB open / read / write / evict
- coverSiblings.ts cover-size sibling probing + upgrade race
Verbatim function-body moves. The only non-move changes are three
named wrappers (cancelScheduledEvict, clearAllUrlEntries, clearCoverState)
that let clearImageCache reach cross-module state — each is the original
lines wrapped in a function, behaviour identical. All seven importers
import from utils/imageCache unchanged.
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.
Add scripts/check-css-import-graph.mjs and run it from npm test and
test:coverage so missing relative CSS imports fail CI like Vite/postcss.
Document the step in src/test/README.md; trigger frontend workflow when
the script changes.
The components.css split (PR #657) wrote `result-items.css` to disk, but
.gitignore line 57 (`result-*`) silently filtered it out of `git add -A`.
Local builds worked because the file existed on the splitter's machine;
fresh clones fail with `ENOENT: no such file or directory` when vite
resolves the `@import './result-items.css';` in components/index.css.
Force-adding with `git add -f` keeps the broad `result-*` ignore for
test artifacts intact.
tracks.css (539 LOC) → 8 per-section files in src/styles/tracks/ +
an index.css. Same mechanic as the theme/components/layout splits.
Concatenating reproduces the original byte stream (+1 trailing newline).
Generated via /tmp/split-tracks-css.mjs.
layout.css (3209 LOC) → 29 per-section files in src/styles/layout/ +
an index.css that imports them in original cascade order.
Same mechanic as theme.css + components.css splits: top-level sections
detected by single-dash or 3+ dash header decoration. Concatenating in
@import order reproduces the original byte stream (+1 trailing newline,
cosmetic).
Generated via /tmp/split-layout-css.mjs.
components.css (14205 LOC) → 84 per-section files in src/styles/components/ +
an index.css that imports them in original cascade order.
Same approach as the theme.css split: top-level sections are detected by
single-dash or 3+ dash header decoration (/^\/\* ─(?: |─{2,})/), 2-dash
sub-sections stay inside their parent. Concatenating in @import order
reproduces the original byte stream (+1 trailing newline, cosmetic).
Each section is now self-contained — touching Tracklist, Modal, Hero,
Sidebar, etc. only opens one focused file.
Generated via /tmp/split-components-css.mjs.
theme.css (16138 LOC) → 122 per-section files in src/styles/themes/ +
an index.css that imports them in original cascade order.
Concatenating all files via index.css reproduces the original byte stream
(+1 trailing newline, cosmetic).
Each top-level section header in theme.css (matching /^\/\* ─{3,}/)
becomes its own file, slugged from the header text (or the [data-theme]
selector found in the body when the header was a banner). Pure-separator
headers fold into the previous section so they don't create empty files.
Generated via /tmp/split-theme-css.mjs.
* 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 pulling the remaining lifecycle code out of
DeviceSync.tsx. 928 → 638 LOC (−290).
useDeviceSyncBrowser — playlists / randomAlbums / artists state +
their three loaders + the tab-switch useEffect that lazy-loads on
first visit + the 300 ms debounced album-search useEffect + the
expandedArtistIds / artistAlbumsMap / loadingArtistIds state with
toggleArtistExpand. Takes activeTab + search + a resetSearch
callback (so the tab-switch effect can clear the search input the
page still owns).
useDeviceSyncDeviceScan — scanDevice useCallback + the on-mount
useEffect + the auto-import-manifest useEffect (with the
manifestImportedRef gate so it only fires once per drive plug-in)
+ the clean-on-unplug useEffect that clears deviceFilePaths and
resets the import flag.
useDeviceSyncJobEvents — the device:sync:progress and
device:sync:complete event listeners. Complete handler dispatches
the toast, writes the manifest, generates per-playlist m3u8 files
(through fetchTracksForSource + trackToSyncInfo), and triggers
scanDevice. Cancelled state is preserved by re-calling
useDeviceSyncJobStore.cancel() after complete().
runDeviceSyncChooseFolder — the openDialog → setTargetDir →
optional manifest auto-import → scanDevice timer flow.
DeviceSync drops every direct import that those hooks now own
(getPlaylists, getArtists, getArtist, getAlbumList, searchSubsonic,
listen, openDialog, useEffect, useRef, SubsonicPlaylist /
SubsonicArtist / SubsonicAlbum type imports). Pure code move
otherwise — no behaviour change.
Four-cut cluster pulling the orchestrators out of DeviceSync.tsx.
1179 → 905 LOC (−274).
useDeviceSyncDrives — drives state + drivesLoading + refreshDrives
callback + the 5 s polling useEffect + the activeDrive memo that
matches targetDir against any detected drive's mount_point.
Returns { drives, drivesLoading, activeDrive, driveDetected,
refreshDrives }.
useDeviceSyncSourceStatuses — owns sourcePathsMap state + the
useEffect that computes per-source paths through compute_sync_paths
(parallel for all sources, with the cancellation guard), and the
derived sourceStatuses Map keyed on 'synced' / 'pending' /
'deletion'.
runDeviceSyncMigration — runDeviceSyncMigrationPreview (read v1
manifest's filenameTemplate, fetch album-source tracks, compute
new paths via Rust + old paths via JS legacy template, diff into
pairs + collisions + unchanged) and runDeviceSyncMigrationExecute
(invoke rename_device_files, bump manifest to v2, rescan device).
Exports MigrationPhase / MigrationPair / MigrationResult types so
the page state stays typed.
runDeviceSyncExecution — runDeviceSyncSummaryPrompt (input
validation + invoke calculate_sync_payload through the subsonic
client) and runDeviceSyncExecute (delete pending sources, re-write
playlist m3u8 even when nothing to download, fire sync_batch_to_device
with the right toast variants on space / mount / generic errors).
SyncDelta type moves with it.
DeviceSync drops the inline definitions; closeMigration stays
inline (six lines, no real win extracting it). Pure code move
otherwise — no behaviour change.
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.