Commit Graph

359 Commits

Author SHA1 Message Date
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
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.
2026-05-14 14:27:44 +02:00
Frank Stellmacher ff99b10faa refactor(album-detail): I.7 — split AlbumDetail.tsx 511 → 369 LOC across 5 files (#679)
* 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.
2026-05-14 01:19:58 +02:00
Frank Stellmacher 6552d2a5cb refactor(artists): I.4 — split Artists.tsx 520 → 233 LOC across 6 files (#676)
* 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.
2026-05-14 00:33:27 +02:00
Frank Stellmacher 40dd0bd100 refactor(random-mix): G.88 — extract panels + dedupe track row (cluster, multi-commit) (#655)
* refactor(random-mix): G.88.1 — extract helpers + AUDIOBOOK_GENRES + filter logic

* refactor(random-mix): G.88.2 — extract RandomMixHeader component

* refactor(random-mix): G.88.3 — extract RandomMixFiltersPanel component

* refactor(random-mix): G.88.4 — extract RandomMixGenrePanel component

* refactor(random-mix): G.88.5 — extract RandomMixTrackRow (dedupe genre + main lists)
2026-05-13 18:21:50 +02:00
Frank Stellmacher d4d3b0e53f refactor(folder-browser): G.87 — extract column component + 3 hooks (cluster, multi-commit) (#654)
* refactor(folder-browser): G.87.1 — extract helpers + types

Move ColumnKind / NavPos / Column types + entryToAlbumIfPresent /
entryToTrack mappers + isFolderBrowserArrowKey /
folderBrowserHasKeyModifiers key-event helpers into
src/utils/folderBrowserHelpers.ts. Pure code move.

* refactor(folder-browser): G.87.2 — extract FolderBrowserColumn component

* refactor(folder-browser): G.87.3 — extract useFolderBrowserNowPlayingPath hook

* refactor(folder-browser): G.87.4 — extract useFolderBrowserScrolling hook

* refactor(folder-browser): G.87.5 — extract useFolderBrowserKeyboardNav hook
2026-05-13 18:06:47 +02:00
Frank Stellmacher c8e130ecea refactor(internet-radio): G.86 — extract Toolbar + AlphabetFilterBar + RadioCard + RadioEditModal + RadioDirectoryModal (#653)
* 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/.
2026-05-13 17:47:00 +02:00
Frank Stellmacher bb0fe828bf refactor(artist-detail): G.85 — extract Hero + TopTracks + SimilarArtists + action utilities (cluster) (#652)
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.
2026-05-13 17:26:05 +02:00
Frank Stellmacher ba0bf8aa9d refactor(artist-detail): G.84 — extract helpers + suggestion cover + 2 data hooks + play utilities (cluster) (#651)
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.
2026-05-13 17:07:53 +02:00
Frank Stellmacher c207f748da refactor(favorites): G.83 — extract SongsSectionHeader + SongsTracklist + selection hook (cluster) (#650)
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.
2026-05-13 16:54:48 +02:00
Frank Stellmacher a4b1b29dd6 refactor(favorites): G.82 — extract Top Artists row + Radio favorites row + data hook + song-filtering hook (cluster) (#649)
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.
2026-05-13 16:32:54 +02:00
Frank Stellmacher 7482030a6b refactor(playlists): G.81 — extract PlaylistsHeader + PlaylistCard + action utilities (cluster) (#648)
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.
2026-05-13 16:17:44 +02:00
Frank Stellmacher 6e4ebca938 refactor(playlists): G.80 — extract smart editor open/save orchestrators + polling hook + editor component (cluster) (#647)
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.
2026-05-13 16:04:47 +02:00
Frank Stellmacher 2380543d59 refactor(playlists): G.79 — extract smart helpers + cover images + 2 lazy-fetch hooks (cluster) (#646)
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.
2026-05-13 15:52:09 +02:00
Frank Stellmacher 84c682aeb8 refactor(device-sync): G.78 — extract BrowserPanel + DevicePanel (cluster) (#645)
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.
2026-05-13 15:41:08 +02:00
Frank Stellmacher c13ee5003f refactor(device-sync): G.77 — extract Header + PreSyncModal + MigrationModal (cluster) (#644)
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.
2026-05-13 15:32:52 +02:00
Frank Stellmacher 6fcf2259f6 refactor(device-sync): G.76 — extract browser + device-scan + job-events hooks + choose-folder util (cluster) (#643)
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.
2026-05-13 15:17:19 +02:00
Frank Stellmacher c7946f26b6 refactor(device-sync): G.75 — extract drives hook + source-statuses hook + migration + execution orchestrators (cluster) (#642)
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.
2026-05-13 15:06:49 +02:00
Frank Stellmacher 31542c9923 refactor(device-sync): G.74 — extract helpers + legacy template + fetcher + BrowserRow (cluster) (#641)
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.
2026-05-13 14:57:15 +02:00
Frank Stellmacher 6f8dd73448 refactor(now-playing): G.73 — extract TourCard + DiscographyCard + useNowPlayingFetchers + useNowPlayingStarLove (cluster) (#640)
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.
2026-05-13 14:46:31 +02:00
Frank Stellmacher 2ddc2a2345 refactor(now-playing): G.72 — extract Hero + ArtistCard + AlbumCard + TopSongsCard + CreditsCard (cluster) (#639)
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.
2026-05-13 14:21:31 +02:00
Frank Stellmacher 1c34cc04c7 refactor(now-playing): G.71 — extract helpers + cache + NpCardWrap + NpColumnEl + RadioView (cluster) (#638)
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.
2026-05-13 14:09:37 +02:00
Frank Stellmacher e260669537 refactor(playlist): G.70 — extract three lifecycle hooks (route + bulk-picker dismiss + DnD reorder) (#637)
Three-cut cluster pulling the last cluster of useEffect bodies + the
DnD-reorder visual feedback out of PlaylistDetail.tsx. 452 → 423 LOC
(−29).

usePlaylistRouteEffects — bundles two route-driven effects:
contextMenu reset (clears contextMenuSongId whenever playerStore's
context menu closes) and openEditMeta-from-route (consumes the
`openEditMeta` location.state flag, opens the meta modal, and
clears the flag with a navigate-replace so back-nav doesn't
re-trigger). Subscribes to playerStore directly.

useBulkPlPickerOutsideClick — the global mousedown listener that
closes the bulk-add-to-playlist picker when clicking outside the
picker wrapper. No-op when closed.

usePlaylistDnDReorder — owns dropTargetIdx state, the
container.addEventListener('psy-drop', …) wiring that calls
runPlaylistReorderDrop, and the handleRowMouseEnter
drag-over-visual helper. Subscribes to DragDropContext directly.

PlaylistDetail drops the direct runPlaylistReorderDrop import and
the contextMenuOpen store subscription — both now live in the hooks
that need them. Pure code move otherwise.
2026-05-13 14:01:22 +02:00
Frank Stellmacher 16ee1ea373 refactor(playlist): G.69 — extract runPlaylistLoad + usePlaylistPreview + usePlaylistBulkPlayCallbacks + usePlaylistDerived (cluster) (#636)
Four-cut cluster pulling the remaining handler bodies + memo island
out of PlaylistDetail.tsx. 507 → 437 LOC (−70).

runPlaylistLoad — async fetch + populate flow that the load
useEffect drives: getPlaylist → filterSongsToActiveLibrary →
setPlaylist + setSongs + setCustomCoverId from playlist.coverArt +
rebuild ratings + starredSongs from each song's stored userRating /
starred flag. Takes the six setters as deps. Page keeps the
useEffect that calls it on id / lastModified change.

usePlaylistPreview — startPreview callback (dispatches into
previewStore with the 'suggestions' source) plus the unmount-time
stopPreview cleanup. Returns just { startPreview }. No props
needed.

usePlaylistBulkPlayCallbacks — wraps playPlaylistAll /
shufflePlaylistAll / enqueuePlaylistAll utilities behind three
useCallback handles with the right deps array. Takes
{ songsLength, id, tracks, touchPlaylist, playTrack, enqueue }.

usePlaylistDerived — existingIds, tracks (songToTrack), sorted +
filtered displayedSongs via getDisplayedSongs, displayedTracks (with
the cheap aliasing optimization when displayedSongs === songs),
isFiltered. Subscribes to playerStore's userRatingOverrides +
starredOverrides directly so the page no longer threads them through
the memo deps.

PlaylistDetail drops these direct imports — they followed the new
modules: getPlaylist, filterSongsToActiveLibrary, songToTrack,
useMemo, getDisplayedSongs (now type-only),
playPlaylistAll/shuffle/enqueue from playlistBulkPlayActions.
Pure code move otherwise.
2026-05-13 13:54:26 +02:00
Frank Stellmacher d08875dc70 refactor(playlist): G.68 — extract runPlaylistSaveMeta + 3 hooks (song search / mutations / star+rating) (#635)
Four-cut cluster. 572 → 471 LOC (−101). Each handler on the page
collapses to a one-line delegate; pure code move otherwise.

runPlaylistSaveMeta — the meta save flow (updatePlaylistMeta then
optional uploadPlaylistCoverArt + getPlaylist refresh + cover toast,
or coverRemoved → null, then metaSaved toast + closes the modal).
Takes the deps separately from the opts object so the call-site
just passes through the modal's payload.

usePlaylistSongSearch — searchOpen + searchQuery driven debounced
search against subsonic. Owns the 350 ms timeout, the filter-out
of songs already in the playlist, and the searching state. Returns
{ searchResults, setSearchResults, searching } so addSong on the
page can still drop a just-added song out of the result list.

usePlaylistSongMutations — addSong / removeSong. removeSong is
trivial (filter + setSongs + savePlaylist with prevCount).
addSong preserves the .main-content scrollTop save / requestAnimationFrame
restore trick + drops the song out of both suggestions and search
results + fires the add toast with playlist name interpolation.

usePlaylistStarRating — handleRate (local override + playerStore
userRatingOverride + setRating API) + handleToggleStar (e.stopPropagation,
local set + playerStore starredOverride + star/unstar API). Reads
starredOverrides / setStarredOverride from playerStore directly so
the page doesn't have to thread them through.

PlaylistDetail drops these direct imports (now consumed in the
hooks/utility): updatePlaylistMeta, uploadPlaylistCoverArt, search,
setRating, star, unstar, showToast, useRef. Pure code move.
2026-05-13 13:46:59 +02:00
Frank Stellmacher d0a270d90a refactor(playlist): G.67 — extract usePlaylistCovers + usePlaylistSelection + usePlaylistSuggestions (cluster) (#634)
Three-cut cluster pulling tightly-scoped state + memo islands out of
PlaylistDetail.tsx as custom hooks. 659 → 565 LOC (−94). Each hook
returns the same names the page already used, so call sites stay
identical.

usePlaylistCovers(songs, customCoverId) — the 2×2 cover quad memo,
the four cover-quad URLs (with their stable cache keys to avoid the
buildCoverArtUrl-salt re-render loop documented in the comment),
the customCover fetch URL + cache key, and the blurred background
URL going through useCachedUrl. Returns coverQuadUrls /
customCoverFetchUrl / customCoverCacheKey / resolvedBgUrl. The page
no longer imports buildCoverArtUrl / coverArtCacheKey / useCachedUrl
directly.

usePlaylistSelection(songs, setSongs, savePlaylist) — selectedIds /
lastSelectedIdx state + toggleSelect (with shift-range support) +
allSelected + toggleAll + bulkRemove (savePlaylist + setSongs +
clears selection). Returns the same shape the tracklist props
already destructured. savePlaylist moves a few lines up in
PlaylistDetail so the hook can be called with it.

usePlaylistSuggestions(songs, playlist.id) — suggestions state +
loadSuggestions (genre-weighted random pull, top 10) + the auto-load
useEffect that fires on playlist change. Returns setSuggestions too
so addSong on the page can still filter the just-added song out of
the suggestions strip. The page drops the getRandomSongs import.

Pure code move otherwise — no behaviour change.
2026-05-13 13:33:41 +02:00
Frank Stellmacher 1a3eeea048 refactor(playlist): G.66 — extract ZIP download + bulk play + row drag + reorder drop (cluster) (#633)
Four-cut cluster pulling action bodies out of PlaylistDetail.tsx. 741
→ 635 LOC (−106). Each handler on the page becomes a thin wrapper
around a parameterized utility; pure code move, no behaviour change.

runPlaylistZipDownload — the buildDownloadUrl → invoke('download_zip')
flow with start/complete/fail dispatches to useZipDownloadStore and
the setZipDownloadId hand-off. Takes playlist + id + downloadFolder
+ requestDownloadFolder + the id-setter as deps. PlaylistDetail
loses the invoke / join / buildDownloadUrl / sanitizeFilename imports
that only this handler used.

playlistBulkPlayActions — three exports (playPlaylistAll,
shufflePlaylistAll, enqueuePlaylistAll) with a shared BulkPlayDeps
shape (songsLength + id + tracks + touchPlaylist + playTrack +
enqueue). Same length-guard, same touchPlaylist call, same
shuffle / play / enqueue branches as before.

startPlaylistRowDrag — the threshold-drag dispatch on row mousedown
(5px deadzone, then bulk-songs / playlist-reorder / single-song
payload depending on selection state + filtered-view flag). Takes
the mouse event + idx + songs + selectedIds + isFiltered + startDrag.

runPlaylistReorderDrop — the psy-drop event handler that lives
inside the tracklist useEffect. Parses the custom-event detail,
computes from→to indexes, and updates songs + savePlaylist + clears
dropTargetIdx. The useEffect itself stays in the page because it
wires up the event listener.

PlaylistDetail's import list also drops `invoke`, `join`,
`buildDownloadUrl`, `sanitizeFilename` — they followed the ZIP
helper to its new file.
2026-05-13 13:17:24 +02:00
Frank Stellmacher 0b84a199e4 refactor(playlist): G.65 — extract Tracklist + FilterToolbar + displayedSongs (cluster) (#632)
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.
2026-05-13 13:09:00 +02:00
Frank Stellmacher 2e57f54bb2 refactor(playlist): G.64 — extract PlaylistHero (#631)
`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.
2026-05-13 12:57:20 +02:00
Frank Stellmacher bf8e4fff3f refactor(playlist): G.63 — extract SongSearchPanel + Suggestions (cluster) (#630)
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.
2026-05-13 12:48:56 +02:00
Frank Stellmacher 1b1122f086 refactor(playlist): G.62 — extract CSV match helpers + import orchestrator (cluster) (#629)
Two-cut cluster on PlaylistDetail.tsx. 1761 → 1400 LOC (−361). The page
keeps the same handleImportCsv arrow function (now a thin guard +
delegate), but the matching algorithm and the CSV import pipeline live
in their own modules.

spotifyCsvMatch — six pure helpers: normalizeForMatching,
cleanTrackTitle (the 100-LOC suffix-regex array), levenshtein,
similarityScore, calculateDynamicThreshold, processBatch. All exports.
No state, no side effects, no React deps.

runPlaylistCsvImport — full Spotify CSV import pipeline:
openDialog + readTextFile, parseSpotifyCsv parse step, batched search
with 2-attempt retry, dynamic-threshold scored matching with ISRC
fast path, dedupe against existing + already-queued, savePlaylist
commit, auto-show report modal on issues, toast variant by outcome
(success / warning / error). Takes a deps object with songs / t /
savePlaylist + setSongs / setCsvImporting / setCsvImportReport
setters. PlaylistDetail keeps the `if (!id || csvImporting) return;`
guard on the caller side; id is no longer needed inside the runner
(savePlaylist captures it).

PlaylistDetail drops three direct imports (search, openDialog,
readTextFile, parseSpotifyCsv) and picks up runPlaylistCsvImport.
search comes back as a top-level import because the search-add
overlay useEffect on the same page still hits the Subsonic search
endpoint directly. SpotifyCsvTrack stays as a type-only import for
the csvImportReport state shape.

Pure code move otherwise — no behaviour change.
2026-05-13 12:35:48 +02:00
Frank Stellmacher 84ceb5f423 refactor(playlist): G.61 — extract helpers + CSV import + modal components (cluster) (#628)
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.
2026-05-13 12:17:56 +02:00
Frank Stellmacher 8adad2be6f refactor(settings): G.60 — extract Storage + Servers + System tabs (cluster) (#626)
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.
2026-05-13 02:42:44 +02:00
Frank Stellmacher afd0786e6c refactor(settings): G.59 — extract AppearanceTab (#624)
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.
2026-05-13 02:30:37 +02:00
Frank Stellmacher 8e7dc35d56 refactor(settings): G.58 — extract AudioTab (#623)
Move the Audio tab body into AudioTab: output-device picker (with the
canonicalize + list-refresh dance and the audio:device-changed / -reset
listener), Hi-Res toggle, embedded Equalizer, the normalization block
(off / replaygain / loudness with pre-analysis attenuation slider and
LoudnessLufsButtonGroup), Crossfade + Gapless mutual-exclusion toggles,
preserve-play-next-order, and Track Previews (locations + start ratio +
duration). The audio-devices state (audioDevices, osDefaultAudioDeviceId,
deviceSwitching, devicesLoading) and refreshAudioDevices useCallback move
into AudioTab; preAnalysisEffectiveDb useMemo moves with them.

Settings.tsx 2266 → 1761 LOC (−505). Drops 8 imports that only the audio
tab used (lucide Play/Waves; listen; effectiveLoudnessPreAnalysisAttenuationDb;
LoudnessLufsButtonGroup; Equalizer; audio-device label helpers; the
TRACK_PREVIEW_LOCATIONS / DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB
constants; TrackPreviewLocation type).

Pure code move — no behaviour change.
2026-05-13 02:23:32 +02:00
Frank Stellmacher 7737b35bf8 refactor(settings): G.57 — extract Integrations + Library tabs (#622)
Move the Integrations tab (Last.fm connect/disconnect, scrobbling toggles,
ListenBrainz, Discord RPC) into IntegrationsTab. The Last.fm token+session
poll flow is now owned by IntegrationsTab as a useCallback closing over
useAuthStore directly. Move the Library tab (Random Mix blacklist, Lucky
Mix menu toggle, hard-coded audiobook genre badges, ratings sliders and
mix min-rating thresholds) into LibraryTab; AUDIOBOOK_GENRES_DISPLAY and
the per-row star sliders move with it.

Settings.tsx 2741 → 2266 LOC (−475). Unused imports removed: lastfm api
helpers, LastfmIcon, Shuffle, Star, StarRating, MIX_MIN_RATING_FILTER_MAX_STARS.

Pure code move — no behaviour change.
2026-05-13 02:11:06 +02:00
Frank Stellmacher 320eb97c03 refactor(settings): G.56 — extract Lyrics + Personalisation + Input tabs (#621)
Three tab-section components carved out of the Settings() default-
export body. Each owns its store hooks + local state; Settings()
now only routes via `{activeTab === 'X' && <XTab />}`.

- `LyricsTab.tsx` (~50 LOC, was ~40 inline) — wraps
  `LyricsSourcesCustomizer` + sidebar lyrics style toggles. Owns
  the two `useAuthStore(s => s.sidebarLyricsStyle/...)` selectors.
- `PersonalisationTab.tsx` (~95 LOC, was ~80 inline) — wraps the
  four customizers (sidebar / artist layout / home / queue toolbar)
  with their per-store reset buttons.
- `InputTab.tsx` (~185 LOC, was ~170 inline) — keybindings + global
  shortcuts. Owns the two `listeningFor` / `listeningForGlobal`
  state slots that were previously hoisted into Settings().

13 now-unused imports trimmed (4 customizer-store hooks, 6
keybinding helpers, `Music2/AudioLines` kept since the tab-button
array still uses them as icons, `LayoutGrid/Keyboard` kept for the
same reason).

Pure code-move. Settings.tsx: 3037 → 2741 LOC (−296). Phase-G
journey: 5298 → 2741 LOC (~48% reduction).
2026-05-13 01:57:26 +02:00
Frank Stellmacher 306e56dc2b refactor(settings): G.55 — extract helpers + credits + tab index (#620)
Pull pure helpers, the contributors/maintainers data list and the
tab type/search index out of `Settings.tsx` so the main component
only has tab-section render logic + the orchestrating state left:

- `utils/audioDeviceLabels.ts` — five ALSA-device label helpers
  (formatAudioDeviceLabel + duplicate-disambiguation + sort +
  select-option builder).
- `utils/formatBytes.ts` — formatBytes + snapHotCacheMb.
- `components/settings/LoudnessLufsButtonGroup.tsx` — small chip
  group used by the audio tab.
- `components/settings/settingsTabs.ts` — `Tab` type + legacy alias
  map + `resolveTab` + `SearchIndexEntry` + `SETTINGS_INDEX` + the
  `matchScore` substring-with-fuzzy-fallback scorer.
- `config/settingsCredits.ts` — `CONTRIBUTORS` (~270 LOC of static
  contributor history) + `MAINTAINERS`.

Pure code-move. Settings.tsx: 3552 → 3037 LOC (−515). Settings/G
journey now 5298 → 3037 LOC (~43% reduction).
2026-05-13 01:48:16 +02:00
Frank Stellmacher b138f51332 refactor(settings): G.54 — extract AddServerForm + UserForm + UserManagementSection (#619)
Three self-contained server/user-management components peel ~1000 LOC
out of `Settings.tsx`:

- `AddServerForm.tsx` (~163 LOC) — server URL + credentials + magic-
  string paste form. Used by the Servers tab.
- `UserForm.tsx` (~340 LOC, with `initialUserFormState` + `UserFormState`
  type) — full Navidrome user create/edit form including the
  "save + copy magic string" admin flow.
- `UserManagementSection.tsx` (~485 LOC, with `formatLastSeen` helper) —
  list + CRUD + Trash/Edit row + per-row magic-string-with-password
  modal. Used by the Users tab. Imports `UserForm` directly.

Each component owns its own state, helpers, and modal-portal logic.
Settings.tsx now imports them and threads in props (server URL, admin
token, current username).

Pure code-move. Settings.tsx: 4568 → 3552 LOC (−1016). 18 unused
imports trimmed (navidromeAdmin types/functions, serverMagicString
helpers, ConfirmModal, lucide icons, createPortal).
2026-05-13 01:39:00 +02:00
Frank Stellmacher cec175c4bc refactor(settings): G.53 — extract seven customizer components (#618)
Pull the 11 self-contained components at the bottom of Settings.tsx
out into `src/components/settings/`:

- `HomeCustomizer.tsx` — home-page section visibility toggles.
- `QueueToolbarCustomizer.tsx` — drag-to-reorder queue toolbar
  buttons + per-button visibility. Includes the GripHandle + button
  icons/labels tables.
- `SidebarCustomizer.tsx` — sidebar nav drag-reorder for library +
  system blocks. Includes the GripHandle and the random-nav-mode
  toggle.
- `LyricsSourcesCustomizer.tsx` — lyrics fetch pipeline UI (mode
  switch + drag-reorder source list + static-only toggle).
- `ArtistLayoutCustomizer.tsx` — artist page section drag-reorder.
- `BackupSection.tsx` — export/import buttons with toast feedback.
- `ServerGripHandle.tsx` — single-purpose grip handle used by the
  servers tab in the main Settings body.

Each component owns its own DnD plumbing, label tables and drop
target types. Settings.tsx now only imports the components; one
local `ServerDropTarget` type kept inside `Settings()` because the
main component still owns the server-list DnD state.

Pure code-move. Settings.tsx: 5298 → 4568 LOC (−730). 13 unused
imports trimmed (lucide icons, store types, shallow, layout helpers).
2026-05-13 01:28:07 +02:00
Frank Stellmacher f7b2799d39 refactor(api): F.51 — extract playlists + play queue + radio + statistics (#616)
Four more domain-eng modules out of `api/subsonic.ts`:

- `subsonicPlaylists.ts` — `getPlaylists`/`getPlaylist`/`createPlaylist`
  /`updatePlaylist`/`updatePlaylistMeta`/`uploadPlaylistCoverArt`/
  `uploadArtistImage`/`deletePlaylist`. The two upload helpers use
  the Tauri-side CORS bypass (`upload_playlist_cover` /
  `upload_artist_image`).
- `subsonicPlayQueue.ts` — `getPlayQueue`/`savePlayQueue`.
- `subsonicRadio.ts` — Internet Radio CRUD (4 fns), Tauri-side cover
  art ops (3 fns), RadioBrowser search/top + `fetchUrlBytes`.
- `subsonicStatistics.ts` — `fetchStatisticsLibraryAggregates`/
  `fetchStatisticsOverview`/`fetchStatisticsFormatSample` with their
  three per-server-folder caches and the `statisticsPageCacheKey`
  helper. `STATS_CACHE_TTL` renamed from the misleadingly-shared
  `RATING_CACHE_TTL` constant.

Also: drop ~20 now-unused type/value imports from `subsonic.ts`, trim
three orphan jsdoc comments left behind by earlier slices, fix four
`vi.mock` targets (`queueSync`, `playerStore.persistence`) plus the
dynamic `await import('../api/subsonic')` calls in `ContextMenu.tsx`
to point at the new module paths.

Pure code-move. subsonic.ts: 561 → 144 LOC (−417). What's left:
ping/pingWithCredentials/probeInstantMix/scheduleInstantMixProbe +
internal `apiWithCredentials`/`restBaseFromUrl`.
2026-05-13 01:03:13 +02:00
Frank Stellmacher 9606a99efb refactor(api): F.50 — extract 7 small domain modules (#615)
Seven domain-eng splits peel ~200 LOC of read endpoints out of
`api/subsonic.ts`:

- `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`,
  `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders
  for the four /rest endpoints we hand to the browser).
- `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`,
  `setRating`, `probeEntityRatingSupport`. `setRating` still triggers
  the lazy `navidromeBrowse` cache invalidation; the same-folder
  lazy import path is preserved.
- `subsonicSearch.ts` — `search`, `searchSongsPaged`.
- `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`,
  `getNowPlaying`.
- `subsonicAlbumInfo.ts` — `getAlbumInfo2`.
- `subsonicLyrics.ts` — `getLyricsBySongId`.
- `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`.

63 external call sites migrated to direct imports. Four `vi.mock`
targets in the store-level tests pointed at `../api/subsonic` and
were updated to the new module paths.

Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
2026-05-13 00:46:13 +02:00
Frank Stellmacher 006635de4b refactor(api): F.49 — extract library + artists + ratings (#614)
Three domain-eng modules peel ~316 LOC of fetch/mapping out of
`api/subsonic.ts`:

- `subsonicLibrary.ts` — browse + random + per-song fetch
  (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`,
  `getRandomAlbums`, `getAlbumList`, `getRandomSongs`,
  `getRandomSongsFiltered`, `getSong`, `getAlbum`,
  `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus
  the private `albumIdsInActiveLibraryScope` cache).
- `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`,
  `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`,
  `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and
  `similarSongsRequestCount` for the per-library scoping fallback.
- `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus
  `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers
  with the shared 7-min cache. Calls back into Library/Artists for
  the per-id fetch.

51 external call sites migrated to direct imports. No re-export
shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts`
keep their `RATING_CACHE_TTL` constant locally (same 7-min window).

subsonic.ts: 1078 → 762 LOC (−316).
2026-05-13 00:23:53 +02:00
Frank Stellmacher 72030f17fd refactor(api): F.48 — extract subsonic types + client primitives (#613)
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its
two most obvious axes:

- `subsonicTypes.ts` — all ~24 exported interfaces + type aliases
  (album/song/artist/playlist/directory/genre/now-playing/radio,
  random-songs filters, three statistics shapes, search + starred
  results, AlbumInfo, structured-lyrics types, etc.) plus the
  `RADIO_PAGE_SIZE` constant.
- `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` +
  `libraryFilterParams` + `secureRandomSalt` / `getAuthParams` /
  `SUBSONIC_CLIENT`. The credential-bearing API helpers
  (`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`,
  `probeInstantMixWithCredentials`) stay in `subsonic.ts` for now —
  they could move into the client module in a follow-up.

66 external call sites migrated to direct imports from the new
modules (no re-export shims in `subsonic.ts`). Pure code-move;
contract test stays green.

subsonic.ts: 1333 → 1078 LOC (−255).
2026-05-13 00:05:58 +02:00
Frank Stellmacher 4d564e5016 refactor(auth): E.43 — extract authStore types + defaults + helpers (#608)
First slice of the authStore split. Pure code-move: no behaviour change.

- `authStoreTypes.ts` — `ServerProfile`, `AuthState`, plus all union
  types (`SeekbarStyle`, `LoggingMode`, `NormalizationEngine`,
  `DiscordCoverSource`, `LoudnessLufsPreset`, `LyricsSourceId`,
  `LyricsSourceConfig`, `TrackPreviewLocation`, `TrackPreviewLocations`).
- `authStoreDefaults.ts` — `LOUDNESS_LUFS_PRESETS`,
  `DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB`,
  `TRACK_PREVIEW_LOCATIONS`, `DEFAULT_TRACK_PREVIEW_LOCATIONS`,
  `DEFAULT_LYRICS_SOURCES`, `MIX_MIN_RATING_FILTER_MAX_STARS`,
  `RANDOM_MIX_SIZE_OPTIONS`.
- `authStoreHelpers.ts` — `generateId`,
  `sanitizeLoudnessLufsPreset`, `sanitizeLoudnessPreAnalysisFromStorage`,
  `clampMixFilterMinStars`, `clampRandomMixSize`,
  `clampSkipStarThreshold`, `skipStarCountStorageKey`,
  `sanitizeSkipStarCounts`.

12 external call sites migrated to direct imports from the new
modules (no re-export shims left in authStore.ts — applies the
[feedback_prevent_god_modules] rule 5: avoid re-export debt).

authStore.ts: 889 → 518 LOC (−371).
2026-05-12 23:22:28 +02:00
Frank Stellmacher 9fac6eb490 refactor(player): E.42 — migrate playerStore re-exports to direct imports (#607)
Migrates ~74 call sites away from the playerStore re-export shims that
were kept during M0–E.41 to avoid touching 30+ imports per PR. Now
that the bigger refactor work is done, each helper goes back to its
real home:

- `initAudioListeners`, `installQueueUndoHotkey`, `flushPlayQueuePosition`
  → from their own store modules
- `getPlaybackProgressSnapshot`, `subscribePlaybackProgress`,
  `PlaybackProgressSnapshot` → from `playbackProgress`
- `resolveReplayGainDb`, `shuffleArray`, `songToTrack`
  → from `utils/*`
- `_resetQueueUndoStacksForTest`, `consumePendingQueueListScrollTop`,
  `registerQueueListScrollTopReader` → from `queueUndo`
- `PlayerState`, `Track` types → from `playerStoreTypes`

Drops the corresponding 13 re-export stubs from `playerStore.ts` and
the now-unused imports. Also drops dead section banners + per-wrapper
comments above one-line action delegates. Trims one stale "(separate
PR)" note in `transportLightActions.ts` since that follow-up landed
in E.39.

`playerStore.ts`: 180 → 112 LOC (−68). Down from Phase E's starting
3732 LOC.

`bootstrap.test.ts` mock target updated from `../store/playerStore`
to `../store/queueUndoHotkey` to keep the spy reachable after the
import change.
2026-05-12 22:46:13 +02:00
Kveld. 64b33e6941 feat: customizable queue toolbar with drag-and-drop reordering and visibility toggles (#534)
* Add drag-and-drop reordering and visibility toggles for queue toolbar

* docs(changelog): credit PR #534 (queue toolbar customization)

Adds the v1.46.0 CHANGELOG entry and a new bullet on kveld9's
contributors block in Settings → System.

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-11 12:35:38 +02:00
Frank Stellmacher 57fe847d71 refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic (#508)
* refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic

Settings opened on a tab where four or five sub-sections were expanded
on first render — audio device, theme list, lyrics sources, sidebar
customizer, random-mix copy, offline dir, language picker, keybindings
table. The page felt like a wall of controls before the user had even
looked for something specific. Removed every `defaultOpen` flag from
the SettingsSubSection call sites so each tab now boots with only the
section headers visible. Component default was already `false`.

ThemePicker auto-expanded the group containing the active theme on
mount. Same noise on a screen that already has the longest accordion
list in the app, and the blue dot in the group header already tells
the user which group holds the active theme. Initial open-group is
now `null` — all groups collapsed until the user clicks one.

Font picker had a dropdown-style button that toggled a list inside
the sub-section, which meant two clicks (open the section, then open
the dropdown) for what should be a one-click choice. Removed the
button + the `fontPickerOpen` state — opening the Font sub-section
now reveals the full list directly and a click sets the font without
collapsing anything. OpenDyslexic moved to the top of the list so
users with dyslexia don't scroll past 14 sans-serifs to find their
option; the rest stays in the original order.

* docs: changelog entry for PR #508

Logs the Settings collapse-by-default + font picker cleanup +
OpenDyslexic ordering in v1.46.0 "## Changed".
2026-05-07 22:31:50 +02:00
Frank Stellmacher f520f7951a feat(settings): OpenDyslexic font option for dyslexic readers (#507)
* feat(settings): OpenDyslexic font option for dyslexic readers

Next step on the accessibility track. The first pass was on the colour
side — WCAG contrast audits across every theme and dedicated colour-
vision-deficiency variants for the protanopia / deuteranopia / tritan-
opia palettes. Typography is the other axis: some users with dyslexia
find a font with a heavier weighted baseline and asymmetric glyph
shapes (b/d, p/q never mirror, italic forms differentiated rather
than slanted-regular) easier to track than a typical sans.

Adds OpenDyslexic to the existing Fontsource font picker. SIL OFL
licensed, freely redistributable, and the de-facto open-source
standard for this use case. Non-variable axis, ships as four discrete
weight/style files (regular, bold, italic, bold-italic) — the Settings
picker grew an optional `hint` field on font entries so this one row
can carry a "dyslexia-friendly · no RU/ZH support" subtitle without
bloating the other 14 entries.

Latin + Latin-extended only. Cyrillic and CJK locales (RU, ZH) fall
back to the system font when this is selected; the subtitle calls out
that limitation upfront. i18n: hint string in all 8 locales
(settings.fontHintOpenDyslexic).

Accessibility is intentional product positioning here — it's an
underserved corner of the Subsonic-client ecosystem.

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

* docs: changelog entry for PR #507

Logs the OpenDyslexic font option in v1.46.0 "## Added".

* docs(settings): contributor entry for PR #507

Adds the OpenDyslexic accessibility bullet to Psychotoxical's
contributions list.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 22:15:38 +02:00
Frank Stellmacher bd56177e2c feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page

Rail under Home > mostPlayed and a dedicated infinite-scroll page that
list albums whose tracks are tagged in lossless containers. Walks
Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by
albumId on the way down, stops when the song stream crosses into lossy
(bitDepth==0) or the server runs out of rows. _filters has no operators
on quality columns, so a sort + walk is the only path; equality on
sample_rate / bit_depth probes returned empty (verified).

The page paginates through the song-cursor with an in-flight ref so
overlapping IntersectionObserver fires don't double-add albums, plus a
cancelled flag so React StrictMode's double-mount doesn't apply two
parallel result sets in dev.

Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC
*or* AAC and Navidrome's response carries an empty codec field, so we
can't tell them apart; same story for wma. Allowlist is flac, wav,
aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only
lossless. ALAC-in-m4a setups will miss out, acceptable trade-off
without a reliable codec field.

Status: WIP. Settings home-customizer label and en.ts strings landed,
no other locales yet, no quality badge on AlbumCards across the rest of
the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the
earlier hi-res-only iteration before the lossless broadening.

* wip(home): Lossless page header parity + streaming + sidebar nav

Page header now mirrors All Albums: selection mode with three action
buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the
same handlers Albums.tsx uses, selection-counter title swap, and the
perfFlags.disableMainstageStickyHeader respect path. Filters were
intentionally skipped — the rail is sorted by bit_depth, mixing client-
side filters with server-driven pagination would produce gaps.

Loading feels noticeably faster: ndListLosslessAlbumsPage takes an
optional onProgress callback that fires once per internal fetch with
the entries discovered in that fetch, so the page can stream new
albums into state instead of waiting on the whole loadMore. Page-side
budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst
case vs 5 MB before), since the rail's catch-em-all pass is wrong for
infinite-scroll UX. Subtitle under the page title primes the user that
this is slower than other album pages because Psysonic walks the song
catalog by quality (Navidrome ignores _fields, so per-song responses
ship with lyrics + tags + participants whether we want them or not).

Sidebar nav entry registered under 'losslessAlbums' with a Gem icon,
defaults to visible:false (matches composers / folderBrowser /
deviceSync — niche browsing modes). Existing users get the entry
appended at the end of their persisted sidebar list automatically via
the onRehydrateStorage merge that sidebarStore already runs for new
DEFAULT_SIDEBAR_ITEMS.

i18n: full coverage across all 8 locales for sidebar.losslessAlbums,
home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported
and the new losslessAlbums.slowFetchHint subtitle. ru/zh are
machine-translation quality, flagged for a polish pass.

* feat(home): default Lossless sidebar entry to visible

Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false
to true. Existing installs keep whatever the user has in persisted
storage; fresh installs see the entry in the sidebar from the start.

Earlier wip commit defaulted it off (matched composers / folderBrowser
/ deviceSync as a niche browse mode), but the rail + page do show
something useful for any library with at least one FLAC/WAV/etc. album,
so off-by-default just hid the feature.

* docs: changelog entry for PR #506

Logs the Lossless Albums rail + page + sidebar entry in v1.46.0
"## Added".

* docs(settings): contributor entry for PR #506

Adds the Lossless Albums rail/page bullet to Psychotoxical's
contributions list.
2026-05-07 20:44:27 +02:00
Frank Stellmacher a41e3a624a feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API

Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.

Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.

No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.

Closes discussion #479.

* docs: changelog entry for PR #504

Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".

* docs: settings contributor entry for PR #504, drop competitor mention

Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
2026-05-07 20:22:55 +02:00