Commit Graph

222 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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
Frank Stellmacher d24514d67e refactor(player): E.3 — extract waveform/normalization/seek pure helpers (#566)
Three small tranches out of playerStore.ts:
- src/utils/waveformParse.ts — waveformBlobLenOk + coerceWaveformBins (the
  parser that handles number[] / Uint8Array / ArrayLike payloads Rust
  serializes as).
- src/utils/normalizationCompare.ts — normalizationAlmostEqual (tolerant
  null-aware dB comparison).
- src/utils/seekErrors.ts — isRecoverableSeekError (retry classifier for
  the Rust seek pipeline).

Each gets focused unit tests. All four were file-private, no external
callers — pure code move. playerStore 3556 → 3516 LOC.
2026-05-12 11:45:09 +02:00
Frank Stellmacher dcf3dd98e0 refactor(player): E.1 — extract queue-identity helpers to utils/ (#563)
Four pure helpers move out of playerStore: normalizeAnalysisTrackId,
sameQueueTrackId, queuesStructuralEqual, shallowCloneQueueTracks.
Adds focused unit tests for the stream:-prefix normalization and the
no-op detection that prevents unnecessary queue rewrites.

Behaviour preserved verbatim. playerStore 3618 → 3598 LOC.
2026-05-12 11:08:21 +02:00
Frank Stellmacher d3a8160b37 refactor(player): M0 — extract pure helpers from playerStore.ts (#554)
Moves four self-contained helpers into src/utils/, each with co-located
characterization tests. playerStore re-exports them for the ~30 existing
call sites; Phase E will migrate those imports.

  - shuffleArray              (Fisher-Yates, generic)
  - resolveReplayGainDb       (track/album/auto mode resolution)
  - songToTrack               (Subsonic -> Track shape)
  - buildInfiniteQueueCandidates  (Instant-Mix top-up source)

playerStore.ts: 3732 -> 3618 LOC (-114).
2026-05-12 01:24:04 +02:00
Frank Stellmacher d2898ebaf6 test(api): URL builders + playback URL resolver + share link composition (Phase F3) (#544)
subsonic.contract.test.ts (21): parseSubsonicEntityStarRating (userRating
first then rating fallback, numeric-string coercion, undefined for null /
NaN / non-numeric), libraryFilterParams (empty without active server, empty
on "all" filter, returns musicFolderId on specific filter), getClient
(throws without a server, returns baseUrl + auth params, rotates token + salt
across calls), coverArtCacheKey (serverId:cover:id:size shape, "_" fallback
without active server, no ephemeral salt embedded -- stays cacheable),
buildStreamUrl (URL shape + Subsonic auth params: id u t s v=1.16.1
c=psysonic/* f=json, rotates t/s across calls so Rust matches by id, special
character ids encoded once not twice), buildCoverArtUrl (default size=256),
buildDownloadUrl (download.view path), trailing-slash + scheme handling on
base URL.

resolvePlaybackUrl.test.ts (15): precedence offline > hot-cache > stream
(first priority wins even when later sources also have the track), forwards
trackId + serverId to both stores. getPlaybackSourceKind for offline / hot
/ stream / engine-preload-hint cases. streamUrlTrackId parser (id from
stream.view query, null for non-stream URLs / no query / missing id, decodes
URL-encoded ids, manual-query fallback for relative paths).

copyEntityShareLink.test.ts (5): writes a psysonic2-prefixed payload that
round-trips, returns false without an active server, returns false on
empty / whitespace id, trims surrounding whitespace before encoding,
propagates clipboard-failure return.

Gate broadens with src/utils/resolvePlaybackUrl.ts (95.8 %) +
src/utils/copyEntityShareLink.ts (100 %). subsonic.ts at 12.7 % stays out
-- the URL-builder + parser surface this PR covers is the structural part;
the async API endpoints need axios mocking, deferred to a follow-up.
authStore.ts (79 %) and playerStore.ts (40 %) deferred-list comments
updated to reflect F2 + F1 actuals.
2026-05-11 22:51:29 +02:00
Frank Stellmacher 4f9ad07d65 test(frontend): harness expansion + utility coverage push (F0 + F6) (#539)
* test(frontend): expand harness for store/component/contract tests

- factories: makeSubsonicSong, makeServer, makeAuthState, makeQueueState
- storeReset.ts: per-test reset for player/auth/preview/orbit stores
- mocks/subsonic.ts: realistic fixtures + stream/cover URL helpers
- mocks/browser.ts: ResizeObserver/IntersectionObserver/matchMedia/clipboard/object URLs
- mocks/tauri.ts: tauriMockListenerCount for listener-lifecycle regression tests
- renderWithProviders: pin i18n language to 'en' by default; { language } opt-out
- vitest.config: pool 'forks' + isolate to avoid module-mock + Zustand-global flake
- README: documented patterns, store-reset policy, i18n rule, isolation rationale

* test(frontend): bump utility coverage + expand hot-path gate

serverMagicString: 71→100% (encode/decode rejection branches, clipboard
fallback paths). shareLink: 69→97% (all entity kinds, queue trim, orbit
decoder, findServerIdForShareUrl). dynamicColors: 44→100% (extractCoverColors
DOM paths via Image / canvas / fetch mocks).

Gate adds shareLink.ts and dynamicColors.ts — both stable above 95%.
Comments updated for the new floor and the M4 hard-gate handoff.
2026-05-11 21:11:23 +02:00
Frank Stellmacher a228ce1c91 chore: fix stale doc references (#538)
- src/test/README.md: layout listed wrong filename for the readme itself
- miniPlayerBridge.ts: comment pointed at a doc that lives outside the repo
2026-05-11 16:57:54 +02:00
Frank Stellmacher a702a5dd5b feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log

Multiple users on Discord report Orbit guests stopping after the first
song with no errors anywhere — Settings → Debug → Export Logs is too
buried for non-technical reporters, and the relevant code branches
have no logging at all (silent fail). This adds a one-click "Copy log"
path right inside the Orbit session bar.

The new Activity-icon button next to Help opens a popover with:

- Live mini-display: role, host vs. guest track id + position, drift,
  age of the host's last state write — all updating once a second.
- Scrolling event log textarea fed by an in-memory ring (200 events).
- Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and
  drops them on the clipboard — paste straight into a Discord report.

Instrumentation lands at the previously-silent decision points:

- Guest pull tick: full snapshot of host vs. guest state on every read.
- Each branch of the divergence detection in `useOrbitGuest.ts` logs
  which path it took and why (initial / track-change-followed /
  track-change-diverged / play-pause-flip), making the
  "stuck after first song" symptom diagnosable from the buffer alone.
- Host pushes log track id, isPlaying, queue length, guest count.

Events are also bridged to the existing `frontend_debug_log` Tauri
command when Settings → Logging is on Debug, so power users still get
the same data in `psysonic-logs-*.log` for offline triage.

i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are
native; ES / FR / NB / NL / RU / ZH are first-pass and may want a
polish from native speakers later.

* docs(changelog): add orbit diagnostics popover entry
2026-05-09 21:49:17 +02:00
Frank Stellmacher 59744601d4 feat(composer): Browse by Composer page (issue #465) (#487)
* feat(composer): Browse by Composer page (issue #465)

New library section listing every artist credited as composer on at
least one track, with a detail page showing all works they're credited
on in that role. Targeted at classical-music libraries where the
"recording artist" tag carries the orchestra and the "composer" tag
carries Bach / Mozart / Chopin.

Hits Navidrome's native /api/artist?_filters={"role":"composer"} for
the listing and /api/album?_filters={"role_composer_id":"…"} for the
works grid — Subsonic getArtist only follows AlbumArtist relations and
returns 0 albums for composer-only credits, so the native API is the
only path that works. Requires Navidrome 0.55+ (uses
library_artist.stats role aggregation); on older / pure-Subsonic
servers the page shows a one-line capability banner.

- Two new Tauri commands: nd_list_artists_by_role +
  nd_list_albums_by_artist_role, generic over participant role so
  conductor / lyricist / arranger pages are trivial to add later.
- Composers grid: text-only compact tiles (name + participation count
  pulled from stats[role].albumCount). No avatars — composer libraries
  carry no useful imagery and the listing endpoint exposes no image
  URLs anyway.
- ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the
  full work grid, with a graceful fallback when the artist has no
  external info synced.
- Sidebar entry default off (Feather icon) — opt-in for the niche
  classical use case.
- nd_retry backoffs widened from [500] to [300, 800, 1800] — helps
  every nd_* call survive intermittent TLS-handshake-EOF errors that
  some reverse-proxy setups produce when keep-alive pools churn.
- Distinguishes "server can't do this" (HTTP 400/404/422/501) from
  transient errors so the capability banner only fires when the server
  actually rejects the request shape; everything else gets a retry
  button.
- i18n in all 8 supported locales.

* fix(composer): address review feedback on detail page + role queries

- Re-fetch ComposerDetail when music-library scope changes; previously
  the album grid stayed stale until navigation while the list refreshed.
- Thread library_id through nd_list_artists_by_role and
  nd_list_albums_by_artist_role so role queries respect the active
  Navidrome library, matching the Subsonic musicFolderId already piped
  through libraryFilterParams().
- Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header
  image was stored under the Subsonic cover-art key, aliasing cache
  entries and risking cross-source pollution.
- Consolidate the two contradictory composer-imagery comments in
  Composers.tsx into a single accurate one (the older one referenced an
  Images toggle that was never implemented).
- Align openLink toast duration with ArtistDetail (1500ms -> 2500ms).

* fix(composer): keep bio across scope changes, add share, degrade gracefully

Three remaining items from the latest review pass on the composer flow.

1. Bio survives a music-library scope change.
   The previous fix added musicLibraryFilterVersion to the load effect,
   but that effect also did setInfo(null) while the getArtistInfo effect
   still depended on [id] alone — so a scope bump on the open page
   wiped the bio without re-fetching it. Move the info reset into the
   bio effect (keyed on id) and out of the load effect: the album grid
   still refreshes on scope change; the Last.fm header image and
   biography survive untouched, since both are library-independent.

2. Composers join the share pipeline as a first-class entity kind.
   Extend EntityShareKind with 'composer' (and isEntityKind), branch
   applySharePastePayload to validate via getArtist (same id pool) and
   navigate to /composer/:id, and wire a Share button into
   ComposerDetail. A pasted composer link now opens the composer view
   instead of the artist view, matching what was copied. i18n added in
   all 8 locales (sharePaste.composerUnavailable, openedComposer;
   composerDetail.shareComposer, unknownComposer).

3. Partial server failure no longer hides the works.
   If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page
   used to show full "not found" despite having data to display. Switch
   the not-found gate to require both empty (`!artist && !albums`) and
   render a degraded header (placeholder name, no Wikipedia / favourite
   / share / Last.fm image) when only metadata is missing.

* fix(composer): right-click share copies a composer link, not an artist link

The context menu opened from a composer card / row uses type='artist'
because every composer-action (radio, favourite, rating, add-to-playlist)
is identical to the artist counterpart — they share an id space and a
backend representation. Sharing was the one exception: the "Share Link"
entry produced a 'psysonic2-' string with k='artist', so a paste opened
/artist/:id even though the user came from /composers.

Add an optional shareKindOverride to openContextMenu (default: undefined,
preserves existing behaviour) and have the artist-typed branch consult
it when calling copyShareLink. Composers.tsx now passes 'composer' on
both right-click sites; nothing else changes downstream because the
override only affects the share kind.

* polish(composer): show Last.fm avatar even without server metadata

Two minor follow-ups from the latest review.

- ComposerDetail: drop the `&& artist` guard on the header-avatar render
  path. info?.largeImageUrl can resolve through getArtistInfo(id) without
  ever needing the SubsonicArtist record, so the previous gate hid a
  perfectly good Last.fm portrait whenever getArtist failed but the
  bio fetch succeeded. Replace artist.name with displayName so the
  alt / aria-label degrade to the localised "Composer" placeholder
  instead of empty strings.
- copyEntityShareLink: doc comment now mentions composer alongside
  track / album / artist.

* fix(composer): derive Last.fm cache key from route id, not from artist record

Follow-up to the previous polish: the avatar render path no longer
requires `artist` to be populated, but the cache-key gate still did. So
when getArtist failed but getArtistInfo returned a Last.fm portrait, the
key fell through to coverKey — which is empty without an artist record,
re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix
was meant to close.

Switch the Last.fm branch to the route id (same id namespace as the
SubsonicArtist record), so the key stays stable whenever Last.fm art is
shown, independent of getArtist succeeding.

* docs: CHANGELOG + Contributors entry for composer browsing (PR #487)
2026-05-07 00:36:09 +02:00
Frank Stellmacher 8692e50603 feat(settings): Open Source Licenses section in System tab (#477)
* feat(licenses): tooling and initial data generation

Adds the maintainer-only generator that produces src/data/licenses.json:
- src-tauri/about.toml + about.hbs: cargo-about config + handlebars template
  for the Rust-side license enumeration
- scripts/generate-licenses.mjs: orchestrator that runs cargo-about and
  license-checker-rseidelsohn (via npx, no devDep), merges the outputs into
  a single per-crate JSON with full license texts, and writes the result to
  src/data/licenses.json

The script is invoked directly with `node scripts/generate-licenses.mjs` —
no npm script wrapper on purpose, since adding one to package.json would
trigger the nix-npm-deps-hash-sync workflow on every push.

Initial generation covers 575 cargo crates + 71 npm packages (646 entries
total, all with full license text bundled, ~1.4 MB JSON).

* feat(licenses): Settings panel UI

Adds a new Open Source Licenses section under Settings → System,
sitting below Contributors. Components:

- LicensesPanel.tsx: search input, curated highlight block of ~10 key
  dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual
  list of all 600+ entries
- LicenseTextModal.tsx: full-screen-ish modal showing the bundled license
  text plus name/version/license-id badges + repository link
- licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a
  separate chunk, so the heavy ~1.4 MB payload is only loaded when the
  user actually opens the panel — no runtime fetch, the data is fixed
  into the build artifact)

The panel registers itself in the Settings in-page search index under
the System tab.

* feat(licenses): i18n in 8 locales

Adds the `licenses` namespace (title, intro, highlights, search
placeholder, no-results, loading / load error, no-license-text,
view-source, total line, generated-at) across en, de, fr, nl, zh, nb,
ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay
universal and are rendered as-is.

* docs(release): document licenses regeneration step

Adds a Step A.3 to the release SOP describing the maintainer-only
`node scripts/generate-licenses.mjs` workflow, the cargo-about
prerequisite, and explicitly notes why no npm script wrapper exists
(would trigger the nix-npm-deps-hash-sync workflow).
2026-05-06 14:17:13 +02:00
cucadmuh de3c0d9da1 Feat/performance probe fps overlay (#472)
* feat(perf-probe): add FPS overlay toggle and tidy probe modal

Add optional rAF-based FPS readout controlled by a persisted probe flag.
Remove the separate keyboard shortcut. Collapse all phase sections by default.

* perf(fps-overlay): subscribe only to showFpsOverlay flag

Add usePerfProbeFlag so the overlay does not re-render when other probe
toggles change. Track the animation frame id with a loop-local variable.
2026-05-06 02:13:59 +03:00
cucadmuh c3d37546cf Feat/search improvements (#470)
* feat(covers): race sibling downscale vs fetch, search thumb priorities

Run getCoverArt and client downscale in parallel when another size of the
same cover is cached; first successful result wins and aborts the other path.
Await both branches so inflight bookkeeping does not detach early.

Extend the cover cache size roster so provisional siblings resolve for sizes
used in the UI (e.g. 400/600/800, 48/96).

CachedImage: fetchQueueBias for live/mobile search (artist thumbnails ahead of
albums in fetch-slot ordering); configurable observeRootMargin with a wider
default to prepare priority slightly before elements enter view.

Mobile search adds round artist-thumb styling; add shared cover blob downscale
helper.

* perf(image-cache): batch sibling IDB reads and guard cover size registry

Use one read transaction when probing IndexedDB for sibling cover keys.
Extract COVER_ART_REGISTERED_SIZES and add Vitest coverage so every literal
coverArtCacheKey(_, size) in src stays aligned with sibling invalidation.
Honor AbortSignal during JPEG encode in downscaleCoverBlob.
2026-05-06 01:26:30 +03:00
cucadmuh 9d30285ff1 Perf/UI cover cache mainstage (#468)
* Enhance CachedImage and ArtistDetail components with improved image caching and priority handling

- Refactor CachedImage to utilize a priority system for image loading based on viewport visibility, improving performance during scrolling.
- Update useCachedUrl to accept an optional getPriority function for better cache management.
- Optimize ArtistDetail and Artists components by using useMemo for cover art URLs, reducing redundant calculations and improving rendering efficiency.
- Adjust image loading logic in CachedImage to ensure smoother transitions and avoid unnecessary fetch requests.

* perf(ui): unblock IDB cover art, stabilize mainstage rails and virtual lists

Let IndexedDB reads bypass the network concurrency slot so cached thumbnails
paint without queueing behind remote fetches; debounce disk eviction during
heavy scrolling.

Fix mainstage horizontal rails: dedupe album/song ids for React keys, widen
artwork budget overscan, avoid resetting the budget on list append, and raise
Home initial artwork budgets. CachedImage treats already-decoded images as
loaded; rail cards load cover images eagerly.

Refresh dynamic color extraction and extend virtual scrolling / scroll roots on
Albums, Artists, Playlists, and related surfaces.


Remove local agent-only commit instructions from the repository tree.

* perf(virtual): viewport-based overscan for main scroll lists

Drive TanStack Virtual overscan from measured scroll height so each list
renders about one screen of extra rows above and below the viewport for
snappier scrolling on Albums, Artists (list mode), and Tracks virtual song list.

Introduce useResizeClientHeight helpers (ID + ref) for ResizeObserver-based
clientHeight tracking.

* docs(changelog): note PR #468 UI cover cache, rails, and virtual lists

Add a coarse summary under 1.46.0 Changed for cover-art pipeline,
mainstage rails, viewport-based overscan, and library/chrome polish.
2026-05-06 00:15:58 +03:00
cucadmuh a6cc2e2ad4 perf(linux): WebKit probe, throttled progress IPC, snapshot playback UI (#452)
* feat(linux): optional native GDK for Nix gdk-session

Introduce PSYSONIC_ALLOW_NATIVE_GDK so main skips the default GDK_BACKEND=x11
pin when the Nix gdk-session wrapper sets the flag. Remove GDK_BACKEND from
the npm tauri:dev script so it does not override nix develop defaults.

* fix(ui): portal server switch menu above sidebar

Main column stacks below the sidebar (layout z-index), so an in-tree dropdown
could never win over the left nav. Render the menu via createPortal to
document.body with fixed coordinates, matching the library scope picker.

* feat(perf): add mainstage probe controls and cut WebKit repaint load

Add a dedicated performance probe surface for mainstage/home toggles and wire Linux CPU diagnostics to isolate expensive UI paths. Tune waveform drawing and Home artwork clipping/windowing so visible content loads immediately while reducing WebKit compositor pressure during playback.

* fix(perf): stop hero rotation when section is off-screen

Gate hero auto-rotation and backdrop crossfade by real viewport visibility using the actual scrolling ancestor. This prevents periodic 10-second CPU spikes from hidden hero updates while preserving normal behavior when the hero is visible.

* fix(perf): isolate player progress updates from mainstage diagnostics

Add probe toggles for PlayerBar waveform and live progress UI updates to confirm playback progress churn as the main CPU driver.
Restore Home artwork quality defaults and keep visual-degradation modes opt-in via debug flags only.

* fix(hero): resume background and autoplay after viewport return

Re-check hero visibility on focus/visibility changes and add a short recovery poll while off-screen so missed scroll/RAF events cannot leave hero animation paused.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(perf): decouple playback progress from mainstage compositing pressure

Throttle audio progress delivery and route live seekbar timing through a lightweight progress channel to cut focus-time WebKit CPU spikes.
Add focused diagnostics in Performance Probe and restore hero/waveform behavior so visuals remain stable while profiling.

* fix(debug): open performance probe with Ctrl+Shift+D

Replace logo-triggered opening with a keyboard shortcut and keep logo purely decorative to avoid accidental probe activation.

* docs(changelog): document experiment/performance probe and playback work

Add an [Unreleased] section for the performance probe, throttled audio
progress IPC, snapshot-based live UI updates, WaveformSeek scheduling
over the same canvas bar renderer, Hero/Home rail fixes, and Linux/Nix
GDK dev ergonomics.

* perf(linux): add WebKit probe, throttle progress IPC, snapshot playback UI

Ship Performance Probe (Ctrl+Shift+D), Rust-throttled audio:progress, a
playback progress snapshot channel with coarse Zustand timeline commits,
Linux /proc CPU readout for the probe, Hero and Home rail artwork fixes,
Tracks SongRail windowing parity, MPRIS cleanup, gated perf counters, and
WaveformSeek paused-seek correctness. Documented in CHANGELOG for PR #452.

* docs(changelog): fold perf work into 1.45.0 and refresh date

Drop the separate 1.45.1 heading; keep PR #452 notes under 1.45.0 Added and
set the section date to 2026-05-04. Restore the safety preface before the
versioned sections.

* docs(changelog): order 1.45.0 Added entries by PR number

Sort the 1.45.0 release notes so subsections follow ascending PR id (390
through 452), with PR #452 last.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 20:06:49 +03:00
Frank Stellmacher 3b4d54431b feat(random-mix): playlist size selector + filter panel layout cleanup (#445)
* feat(random-mix): playlist size selector + filter panel layout cleanup

Adds a 5-button playlist-size picker (50/75/100/125/150) at the top of
the Random Mix filter panel, persisted via authStore. Clicking a size
immediately reruns the current mix (genre-scoped or All Songs) at the
new size — no second click on Remix needed.

Filter panel layout cleaned up:

- Two sub-sections "MIX SETTINGS" and "EXCLUSIONS" with a divider
  between them so the panel reads cleanly with the new size row.
- Larger panel-level headers (FILTERS / GENRE MIX) so the hierarchy
  panel-title > sub-section is visually unambiguous.
- Italic muted note under MIX SETTINGS calling out that large mix
  sizes may return fewer unique tracks if the server's random pool
  runs short — sets honest expectations instead of users wondering
  why a 150 request returned ~126.

fetchRandomMixSongsUntilFull now scales batch size, max-batch ceiling
and dup-streak budget with target size; when no Settings-level mix
filter is active, the first call asks for the full target so a 150
mix can finish in a single round-trip on most libraries. The loop
falls through to top up with deduped follow-up calls if the server
returns fewer than requested.

* docs(changelog): add #445 Random Mix playlist size selector entry

* chore(credits): add #445 to Psychotoxical contributions
2026-05-03 19:57:08 +02:00
Frank Stellmacher 7064ca500e feat(stats): shareable Top-Albums card export (#425)
* feat(stats): add shareable Top-Albums card export

Adds a shareable PNG export of the user's most-played albums,
available from the Statistics page under the "Most Played Albums"
section header.

Layout:
* 3 formats — Story (1080×1920, 9:16), Square (1080×1080), Twitter
  Card (1200×675, 16:9). Story and Square stack a centered URL footer;
  Twitter packs logo + label + URL into a single header row.
* 3 grid sizes — 3×3 (default), 4×4, 5×5. Modal fetches up to 25
  frequent albums on open so larger grids work even when the entry
  surface only loaded a handful.
* Cover tiles render the rank + play count in a thin black info strip
  at the bottom of the cover (rank left, "N Plays" right).
* Header label is hardcoded English ("Top Albums") so a shared image
  remains legible to followers who don't share the user's UI language.
  Caller can override via `opts.meta`.

Source path is local-only — `getAlbumList(type='frequent')` from
Subsonic. Last.fm is intentionally not used.

Implementation:
* `src/utils/exportAlbumCard.ts` — pure Canvas-API renderer. Reads
  theme accent / bg from the document's CSS variables so the export
  matches the active theme. Cover art is loaded through the existing
  `getCachedBlob` IndexedDB cache, so repeated exports are cheap.
  Wordmark is rendered from the `PsysonicLogo` React component via
  `renderToStaticMarkup`, with both gradient stops baked to a single
  accent color for crisp single-tone branding.
* `src/components/StatsExportModal.tsx` — UI: format + grid pickers,
  live downscaled preview, native save dialog via
  `@tauri-apps/plugin-dialog` + `plugin-fs`.
* `src/components/AlbumRow.tsx` — adds optional `headerExtra` slot so
  the share button sits next to the existing scroll-nav arrows.
* i18n keys added under `statistics.*` (`en`, `de`) — other locales
  fall back to English.

* docs(changelog): add Top-Albums card export for PR #425
2026-05-02 16:44:29 +02:00
cucadmuh 9ad0f8af6d feat(ui): UI refinements — sidebar indicators, adaptive header, and interaction polish (#397)
* feat(ui): unify queue toggle handle behavior

Show the queue toggle in the header when the queue is collapsed and use a seam-aligned drag handle when it is open. Hide the seam handle while the main content is actively scrolling to reduce accidental interactions.

* feat(ui): add adaptive header search collapse behavior

Collapse header search to a magnifier when top controls get crowded and expand it as an overlay only while active. Use measured header space with hysteresis to avoid flicker and keep neighboring controls stable.

* chore(ui): remove leftover search prototype artifacts

Drop an unused icon import from live search and remove an unused header container-type rule left from an earlier layout experiment.

* feat(ui): persist sidebar and queue visibility state

Save left sidebar collapse and right queue open/closed visibility in local storage helpers so both panel modes are restored after app restart.

* feat(ui): unify player overflow menu behavior

Use a single overflow menu for click and wheel interactions, with a volume-only mode that keeps the same layout and volume controls as the full menu.

* feat(ui): add wheel seek controls to waveform

Apply 10-second wheel seek steps with trailing 1-second debounce and keep the waveform preview stable so the playhead moves smoothly during rapid scroll input.

* fix(now-playing): stabilize narrow dashboard layout

Switch now-playing responsiveness to container-based breakpoints and prevent stacked widgets from overlapping when width is constrained.

* fix(search): reduce collapse jitter and avoid header overlap

Add a short collapse-state cooldown to prevent threshold flicker and hide conflicting header controls while collapsed search expands as an overlay.

* fix(i18n): localize player overflow controls across locales

Replace hardcoded player overflow labels with translation keys and add the missing keys for all shipped locale files.

* fix(search): keep advanced control clickable in collapsed mode

Prevent focus loss on the advanced search button in collapsed overlay mode so its click handler consistently runs.

* fix(i18n): restore queue translation in offline library

Use the existing queue.appendToQueue key for the offline enqueue button tooltip and label instead of a missing key and hardcoded English text.

* fix(ui): apply overlay scrollbar to right-panel text tabs

Switch now-playing content, lyrics, and info panes to OverlayScrollArea and harden tour-item layout so long concert metadata stays within panel bounds.

* fix(ui): add unread indicator for new releases and guard sidebar drag clicks

Track unread new-release IDs per server/library scope and clear the badge when opening the New Releases page. Also prevent click-through navigation after sidebar drag release and keep related i18n/responsive sidebar-adjacent refinements in this snapshot.

* fix(ui): stabilize live dropdown layering and unread reset flow

Render the topbar Live dropdown via a portal so it consistently overlays sidebar layers. Rework new-releases unread tracking to handle library scope baselines, ignore stale refresh races, and mark items as seen after a 5-second stay on the New Releases page.

* feat(ui): add localized New badges for recently added albums

Show a theme-consistent New badge on album cards and album detail for albums created within the last 48 hours. Localize the badge label across all supported locales and centralize recency logic in a shared utility to avoid duplication.

* fix(album): prevent tracklist jump when entering multiselect

Move bulk selection actions from the tracklist body into the album toolbar next to the track filter. Keep selection controls stable in the header area so enabling multiselect no longer shifts the tracklist content downward.

* fix(tray): add playback-state badge and finalize queue handle tooltip

Show play/pause/stop icons in the Linux tray now-playing entry and persist state safely in Tauri managed state.
Also switch the queue-resize handle tooltip to the dedicated localized key across all locales.

* fix(header): prioritize search collapse before Live/Orbit labels

Make topbar compression deterministic by collapsing search first and compacting Live/Orbit labels only in sustained low-space mode.
Add sticky hysteresis-based header compact state to prevent oscillation while resizing.

* fix(ui): stabilize header compaction and show tray state icons

Prevent topbar flicker in the narrow-width range by tightening compact-mode thresholds, gating on real overflow, and removing width transitions from live search.
Also include playback state icons in tray tooltip text across platforms while preserving tooltip length limits.

* fix(tray): keep tooltip iconization Windows-only

Revert Linux tray tooltip/title fallback attempts and keep state icons only in Windows tray tooltips, while Linux continues to show playback state in the now-playing menu entry.

* fix(ui): restore queue resize response after overlay scroll interactions

Hide the queue handle while scrolling on both the main route viewport and the now-playing viewport, and clear stale thumb-drag state before starting queue resize.
Also ignore inactive/faded overlay thumbs in resizer suppression so horizontal pointer transitions no longer leave the queue seam unresponsive.

* docs(changelog): summarize ui-refinements branch features

Document the branch-level feature additions in 1.45.0 as separate changelog sections and group remaining branch-local fixes under a single polish entry.

* docs(changelog): add PR #397 references for ui-refinements

Attach PR metadata to the new 1.45.0 ui-refinement sections and the polish entry so release notes map directly to the merged branch discussion.
2026-05-01 15:42:40 +03:00
cucadmuh ef45b726ad fix(changelog): resolve embedded notes by semver core (#373)
Whats New and the post-update modal required an exact ## [version] header,
so prerelease package versions (rc, dev) often had no matching section.
Match on major.minor.patch first; if several sections share the core,
prefer a plain X.Y.Z heading when present.
2026-04-29 22:36:57 +00:00
cucadmuh 87373edb17 fix(loudness): target sync, effective pre-analysis trim, and queue/settings copy (#333)
* fix(loudness): target sync, -14 pre-analysis ref, queue UI, and reseed

- Front: coalesce loudness refresh by target LUFS; replay-gain IPC dedupe keys
  include norm target and effective pre-attenuation so TGT changes apply.
- Rust: placeholder gain before integrated LUFS uses pivot at -14 LUFS; UI gain
  from effective trim; reseed loudness after delete when waveform cache would skip.
- Pre-analysis: store attenuation relative to -14 LUFS; engine and UI use an
  offset for other targets; migrate legacy absolute values on rehydrate.
- Queue/Settings: Loudness/TGT labels vs value buttons; styles; i18n for help.

* fix(i18n): simplify loudness pre-analysis helper copy

Remove reference-target wording from loudness pre-analysis helper text and keep
only the effective adjustment shown for the current LUFS target in all locales.
2026-04-27 02:54:58 +03:00
cucadmuh cf09fd4bd3 fix(player): Lucky Mix + mix rating filter (Navidrome / OpenSubsonic) (#332)
* fix(player): Lucky Mix respects mix rating filter and harden rating reads

Wire Lucky Mix through the same Settings → Ratings filter as Random Mix
(enrich + passesMixMinRatings). Prefetch artist/album ratings via
getArtist/getAlbum using both userRating and Navidrome-style rating,
and bump the in-memory prefetch cache key so stale misses are not reused.
Resolve artist entity id from OpenSubsonic artists[] or contributors[]
when top-level artistId is missing; read rating on child song and artist
refs so thresholds apply consistently.

* chore: clarify JSDoc for mix rating enrich vs Lucky Mix filter
2026-04-27 01:30:41 +03:00
Maxim Isaev 39f4d03da5 feat(player): queue undo/redo with hotkeys and playback-aware restore
Snapshot queue, current track, time, and pause before queue mutations.
Ctrl+Z/Cmd+Z restores prior state; Ctrl+Shift+Z/Cmd+Shift+Z reapplies undone
edits when the redo stack is non-empty; new edits clear redo.

Resync the Rust audio engine only when the current song identity changes
from the pre-apply state so reorder/enqueue-style edits keep live playback.

Register the document capture listener from main.tsx after the window label
is set. Mini player forwards undo/redo via mini:undo-queue and mini:redo-queue.
2026-04-27 00:17:10 +03:00
cucadmuh 218aa00718 fix(analysis): CPU seed queue, single waveform emit, and log URL redaction
Serialize heavy PCM seeding through a dedicated queue with optional priority
for the current track. Emit waveform-updated once per completed seed, fix
Lucky Mix waveform refresh tokens, redact Subsonic URLs in logs, and align
hot-cache prefetch with the queued path.
2026-04-26 20:59:33 +03:00
Frank Stellmacher c4a283b809 fix(imageCache): share refcounted blob URLs across consumers (Windows perf) (#321)
The previous design handed every <img> its own URL.createObjectURL for the
same cached Blob. WebKitGTK shrugged it off, but Chromium/WebView2 keys
its decoded-image cache by URL — so identical thumbnails were re-decoded
once per instance. On Windows this made cover/artist grids painfully slow
even when blobs were warm in memory.

Refactor the URL layer to be refcounted and shared:

- New acquireUrl(cacheKey) / releaseUrl(cacheKey) API. First acquire
  creates the URL; subsequent acquires return the same string and bump
  the refcount. Revoke is deferred 500 ms after the count hits zero so
  in-flight decodes finish cleanly.
- useCachedUrl uses a lazy useState initializer: when the blob is hot,
  the very first <img src> is already the blob URL. No fetchUrl→blobUrl
  swap, no decode thrash, no race against the LRU.
- CachedImage passes fallbackToFetch=false: previously the <img>
  briefly carried the raw server URL while the blob resolved, which
  triggered an HTTP fetch that the browser then aborted when src
  flipped to blob: — visible in DevTools as a flood of "Pending / 0 B"
  requests. Memory hits remain instant via the synchronous acquire
  path; cold paths now do a single fetch via getCachedBlob.
- invalidateCacheKey / clearImageCache now also purge URL entries.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:17:12 +02:00
Psychotoxical ab1b1dcffa fix(imageCache): give every cached <img> its own object URL
The previous design kept a single global Map<cacheKey, objectURL> with an
LRU cap of 150 and aggressively revoked the oldest URL on overflow. On
libraries with more than 150 cached covers (artist + album grids quickly
exceed that), an in-use URL would get revoked because a different
consumer pushed it out of the cache, producing the "Failed to load
resource: blob:..." flood that several users have reported.

Refactor the cache to be blob-centric:

- Public API is now getCachedBlob() returning the Blob itself.
- In-memory LRU now holds Blobs (cap 200), not URLs. Map-entry eviction
  drops the strong reference and lets the GC free the Blob once no
  consumer (object URL, <img>, <canvas>) still holds it. No revoke choreography needed.
- useCachedUrl creates its own URL.createObjectURL on blob arrival and
  revokes it on cleanup with a 500 ms grace delay so the DOM <img> has
  time to finish decoding the URL we just took away.
- All existing callers keep their string-returning API; no call-site
  changes outside the hook itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:32:17 +02:00
Psychotoxical 0404a23cc9 Merge branch 'main' into exp/orbit (pre-PR sync)
Conflicts resolved in:
- src/pages/SearchResults.tsx
- src/pages/AdvancedSearch.tsx

Both pages were rewritten on main (PR #303) to use the shared
<SongRow> component with click-to-enqueueAndPlay semantics. Orbit's
playSong helper that branched on orbit-active is no longer needed
at the page level — instead, orbit awareness moved INTO SongRow and
SongCard themselves: in an active orbit session both buttons collapse
into addTrackToOrbit (suggest for guests, host-enqueue for the host)
so we don't ship a queue replacement to every guest.

Also kept main's IntersectionObserver-based pagination on both pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:34:38 +02:00
Frank Stellmacher e3aabd98b7 feat(tracks): add Tracks library hub page (closes #299) (#300)
New /tracks route with three sections:

- Hero "Track of the moment" — random pick with play / enqueue / reroll
- Random Pick rail — 18 song cards, rerollable; hero song deduped
- Browse all tracks — virtualized list (@tanstack/react-virtual),
  paginated 50 at a time

Browse uses Navidrome's native /api/song?_sort=title&_order=ASC for
proper A-Z order (no Subsonic equivalent), with automatic fallback
to search3 on non-Navidrome servers. Search input drives search3
with 300ms debounce. Bearer token cached module-level, re-auth on 401.

Play button on rows + cards calls a new enqueueAndPlay() helper that
appends to the existing queue (skip if duplicate) and jumps to the
song — different from playSongNow which replaces the queue. Enqueue
button stays opaque (no hover-only).

i18n keys for sidebar.tracks + tracks.* namespace in all 8 locales.
New AudioLines sidebar icon. Sidebar entry inserted between
"All Albums" and "Build a Mix".

Performance: cover thumbnails dropped (uniform layout instead),
RAF-throttled scroll prefetch, hover transforms removed from cards
(WebKitGTK compositing-friendly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:23:15 +02:00
Psychotoxical e4cc54a1b5 revert(orbit): drop maxPending cap feature, keep suggestion mute
The pending counter desynced from the actual approval list (state.queue
holds approved items as history, so the count never decreased after a
host approve). The host-pushed pendingApprovalCount workaround didn't
hold up under live testing either, so we're rolling the whole cap
feature back rather than ship something flaky.

What's gone:
- OrbitSettings.maxPending + state.pendingApprovalCount
- cap branch in applyOutboxSnapshotsToState (now back to mute-only)
- maxPending number input in settings popover
- pending counter chip in OrbitQueueHead
- 'cap-reached' branch in evaluateOrbitSuggestGate / OrbitSuggestGateReason
- cap-related toasts in ContextMenu / useOrbitSongRowBehavior
- cap-related i18n keys (suggestBlockedCap, settingMaxPending*, pendingCounter*)
- cap CSS (.orbit-queue-head__pending, .orbit-settings-pop__number)

What stays: per-guest suggestion mute (works correctly) and everything
that fed into both features (OrbitState.suggestionBlocked,
setOrbitSuggestionBlocked, evaluateOrbitSuggestGate, the participants
popover Mic/MicOff toggle, the suggestBlockedMuted toast).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:40:22 +02:00
Psychotoxical 7c379c2111 fix(orbit): pending counter ignored merged/declined items
The "X / Y pending" counter in the queue head and the guest-side
gate-check both used `state.queue.filter(non-host).length`, which is the
*history* count — items the host has already approved or declined still
sit in `state.queue` for attribution lookup, so the counter never
decreased. Reported as "3 / 4 pending" with no actual rows in the
approval list.

The merged / declined sets only exist in the host's local store, so the
guest can't filter them out itself. Solution: the host writes an
authoritative `pendingApprovalCount` into the state blob each tick;
guests (and the host's own UI) read it directly, with a fallback to the
old over-counting behaviour for any older client that doesn't write the
field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:30:57 +02:00
Psychotoxical cfb7e7f6c1 feat(orbit): per-guest suggestion mute + global pending-cap setting
Two anti-spam knobs the host can dial during a live session:

1. Per-guest suggestion mute — Mic / MicOff toggle next to the
   kick/ban buttons in the participants popover. Symmetric (re-enable
   later). State lives in OrbitState.suggestionBlocked: string[]; the
   guest reads it and disables its own Suggest controls so the user
   sees a clear "muted" state instead of silent failures. Host-side
   sweep also drops their outbox entries as a safety net.

2. Max pending approvals cap — number input in the session-settings
   popover, default 0 (= unlimited so existing sessions are unaffected).
   When set, the host sweep stops folding new outbox entries into the
   approval list once the cap is reached. The OrbitQueueHead surfaces
   "X / Y pending" so guests can see when they're getting close.

State changes are additive on the wire — both fields are optional, with
parseOrbitState defaulting them, so older clients keep working.

evaluateOrbitSuggestGate() centralises the guest-side allow/block check
shared between useOrbitSongRowBehavior and the ContextMenu Add-to-Session
items, plus suggestOrbitTrack as a defensive last line.

i18n: en + de + fr + nl + zh + nb + ru + es.

Also fixes an earlier dedupe-key collision: the (user, trackId) cache
keys were missing their separator (NULL byte slipped in during the
previous patch), so two tracks could share a key and one of them
silently overwrite the other. Restored the space separator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:21:51 +02:00
Psychotoxical 2b1124d14a fix(orbit): dedupe guest outbox entries per host sweep
Guest outboxes are append-only from the host's POV — every sweep reads
the same playlist. `applyOutboxSnapshotsToState` was unconditionally
pushing every trackId in every snapshot into `state.queue`, so the
pending-approval list grew by one duplicate per tick for every unhandled
suggestion (visible as 4+ identical rows after ~10 s).

Dedupe against `(user, trackId)` already present in `state.queue` or
`state.currentTrack` before appending. A guest re-suggesting the same
track after it lands/plays is a rare enough case to live with for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:10:40 +02:00