mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
e7431b94b8f46243e09c42ada65b2ee81614d79e
381 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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).
|
||
|
|
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). |
||
|
|
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). |
||
|
|
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). |
||
|
|
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`.
|
||
|
|
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). |
||
|
|
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). |
||
|
|
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). |
||
|
|
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). |
||
|
|
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. |
||
|
|
377675ae94 |
test(ui): MiniPlayer + FullscreenPlayer (§4.5 regression) (Phase F5c) (#548)
MiniPlayer.test.tsx (4): mounts without throwing, renders the
always-present titlebar controls (Pin + Open main window — Close is
Linux-only and lives on the manual smoke list per pick 4a), click on
Open main window / Close does not throw, clicking the Pin button
flips the alwaysOnTop label between "Unpin" and "Pin on top". Bridge
contract (mini:ready / mini:sync, geometry persistence) deferred to
B-tier phase B5 -- jsdom does not model two webviews.
FullscreenPlayer.test.tsx (9): renders the labelled Fullscreen Player
dialog + Close Fullscreen control. Control wiring: Close calls
onClose, Stop calls stop, Previous calls previous, Next calls next,
Repeat cycles via toggleRepeat.
§4.5 of the v2 plan -- useCachedUrl(coverUrl, coverKey, false)
regression. Mocks the CachedImage module so the call args are
observable. Pins:
- the 500 px cover-art call passes opt=false (no fetchUrl fallback;
prevents double crossfade fetchUrl -> blobUrl);
- the 300 px art-box call passes opt=true (default behaviour).
A refactor that "tidies up" the useCachedUrl call sites would silently
regress the FS player cover; this test makes it loud.
Harness fix: vi.mock for @tauri-apps/api/event now returns an async
emit that resolves -- components chain .catch() on emit which crashed
on the bare vi.fn() return value during first-render useEffect.
Benefits any future component test that mounts something using emit.
Frontend suite: 399 -> 412 tests (+13). F5 (and Phases F0-F6) complete.
|
||
|
|
2c38db6ea6 |
test(ui): QueuePanel (§4.4 DnD regression) + PlayerBar (Phase F5b) (#547)
QueuePanel.test.tsx (8): empty-queue affordance, one row per queue track with matching data-queue-idx, track titles render. Toolbar exposes Shuffle queue / Save Playlist / Load Playlist / Copy queue share link / Clear via aria-label; Shuffle is disabled when the queue has fewer than 2 tracks. §4.4 of the v2 plan -- DnD architecture pin. Queue rows do NOT declare draggable=true (no HTML5 native drag); the source file has no dataTransfer.setData / dataTransfer.getData / onDragStart / onDrop JSX usage; no application/json MIME anywhere. The project's psy-drop custom event system sidesteps WebView2's text/plain-only restriction by avoiding HTML5 DnD entirely -- a refactor that re-introduces native DnD on the queue would silently break Windows. PlayerBar.test.tsx (9): renders the labelled "Music Player" region. Surfaces Previous Track / Play / Next Track / Repeat / Stop when a track is loaded. The middle control flips between "Play" and "Pause" based on isPlaying. Control wiring: clicking Play/Pause calls togglePlay, Previous calls previous, Next calls next, Repeat cycles off -> all -> one, Stop calls stop. The region landmark is still rendered when no track is loaded. Adds a generic scrollIntoView no-op stub in src/test/mocks/browser.ts -- jsdom does not implement it and QueuePanel's queueAutoScroll calls it on mount with auto-advance enabled. Benefits any future component test that touches scroll affordances. Frontend suite: 382 -> 399 tests (+17). |
||
|
|
fae615fdb8 |
test(ui): ContextMenu + WaveformSeek behaviour pins (Phase F5a) (#546)
ContextMenu.test.tsx (11): renders nothing when closed, renders when openContextMenu has run, closeContextMenu hides on next render. Song-type shows Play Now / Play Next / Add to Queue; album-type shows Open Album / Play Next / Enqueue Album / Go to Artist; artist-type shows Start Radio + share affordances; queue-item shows a Remove option the song menu does not. Clicking an action calls the expected store method (playNext, enqueue) and closes the menu. Escape on the menu closes it. WaveformSeek.test.tsx (8): renders a canvas, cursor=default without trackId (no-track-loaded affordance), cursor=pointer with a trackId. Wheel guards: no seek without a trackId, no seek with duration=0. Wheel commit wiring: 350 ms trailing debounce delays the seek call until activity settles; rapid wheel events coalesce (fewer commits than events). Mount + unmount completes without throwing. Neither component joins the hot-path gate -- jsdom skips canvas drawing and these files have large branch surfaces (ContextMenu has 13 menu types, WaveformSeek has the animation loop + 11 seekbar styles) that need behavioural smoke rather than line coverage. The tests cover the contract the refactor must preserve; visual / interactive layers stay on the manual smoke list. Frontend suite: 363 -> 382 tests (+19). |
||
|
|
123fbcc802 |
fix(orbit): event-driven host push + guest seekbar lock (#537)
* fix(orbit): event-driven host push on play/pause flips Without this, the worst-case delay between "host hits pause" and "guest stops" is two full polling windows (host's 2.5 s push tick + guest's 2.5 s read tick, plus network) — long enough for the guest to noticeably run past the host. Subscribing to playerStore.isPlaying changes adds at most one extra remote write per flip; non-flip state ticks still ride the existing 2.5 s timer. The listener filters on isPlaying so the per-second currentTime ticks don't trigger spurious pushes. * fix(orbit): lock seekbar for guests — sync follows the host Guests could drag/click/wheel the seekbar, which would jump the local player and then snap back at the next host poll (2.5 s of inconsistent UX) — or push the guest into a diverged state where Catch Up was the only way back. The seekbar is host-controlled in Orbit; the guest input path now reflects that. - App.tsx exposes `data-orbit-role="host"|"guest"` on the root element alongside the existing `data-orbit-active` marker. - WaveformSeek's container gains a `.waveform-seek-container` class so CSS can target it. - Guest rule: `pointer-events: none` on children blocks click / drag / wheel / hover; the parent keeps `cursor: not-allowed` + reduced opacity so the disabled state is visually unambiguous. Hosts and non-orbit users see no change. * docs(changelog): credit PR #537 (orbit sync latency + guest seekbar) |
||
|
|
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> |
||
|
|
02d533e949 |
test(frontend): vitest framework bootstrap + hot-path coverage gate (#536)
* test(frontend): vitest framework bootstrap + hot-path file coverage gate
Adds the harness for component, hook and store tests on top of the existing
util tests in src/utils/. Mirrors the backend rust-tests rollout: jsdom env,
v8 coverage, soft hot-path file gate, dedicated CI workflow.
What's in:
- vitest.config.ts: jsdom environment, v8 coverage, alias @ -> src
- src/test/setup.ts: jest-dom, @testing-library cleanup, vi.mock for
@tauri-apps/api/{core,event} + plugin-shell, Map-backed Storage polyfill
for Node 25 + jsdom 26 (both ship a broken native localStorage)
- src/test/mocks/tauri.ts: programmable onInvoke() / emitTauriEvent() helpers,
auto-reset between tests
- src/test/helpers/factories.ts: makeTrack / makeTracks
- src/test/helpers/renderWithProviders.tsx: render() wrapped with
MemoryRouter + I18nextProvider
- src/test/README.md: conventions doc (where tests go, how to mock Tauri,
what to never mock)
Sample tests showing the patterns:
- src/components/CoverLightbox.test.tsx: component, queries by role
- src/store/previewStore.test.ts: store characterization, event handlers
+ stopPreview (startPreview deferred until the cross-store provider
strategy is decided)
CI:
- .github/workflows/frontend-tests.yml: jobs for vitest, tsc, coverage +
hot-path gate. coverage job carries continue-on-error: true (soft).
- .github/frontend-hot-path-files.txt: initial list (3 utils at >=70%).
playerStore + the unfinished half of previewStore are deferred until
Phase 1 coverage work lands.
- scripts/check-frontend-hot-path-coverage.sh: mirror of the rust gate.
npm scripts:
- test: one-shot run (unchanged)
- test⌚ vitest in watch mode
- test:coverage: v8 coverage + html / lcov / json-summary reports
57 / 57 tests pass; tsc --noEmit clean.
* chore(nix): sync npmDepsHash with package-lock.json
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||
|
|
308eb36f05 |
feat(analysis): re-analyze waveform when clearing loudness cache
Add analysis_delete_waveform_for_track, invoke it from loudness reseed, clear waveformBins in the UI, and extend queue strings for tooltips/toast. |
||
|
|
6c5465e9c7 |
fix(orbit): hysteresis on Catch Up visibility so the button stays clickable (#527)
* fix(orbit): keep Catch Up button visible long enough to click Reported during testing: the Catch Up button "appears for a moment, then disappears too fast to click". The single-stage debounce only filtered the show direction (require ≥ 3 s sustained over-threshold before showing), but on a real-world high-latency session the genuine drift fluctuates between ~1 s and ~8 s in lockstep with both sides' chunked `currentTime` updates — so the button vanished as soon as drift dipped briefly under 3 s, even though the drift baseline was still 5–8 s. Add a hide-side hysteresis: once visible, the button stays visible until drift has been under a tighter 1 s threshold for ≥ 1 s. Otherwise the noisy 1–3 s drift valleys keep the button up so the user can actually click it. Constants left as locals; if testing wants different floors we can extract. * docs(changelog): add catch-up hysteresis bullet * fix(orbit): show Catch Up when host paused + serialise seek-then-pause Two follow-ons after the hysteresis change: **1. Show Catch Up even when the host is paused.** The visibility gate required `state.isPlaying === true`, but a guest who joined while the host was paused still benefits from Catch Up if their sync to the host's paused position failed (engine drop, brief network blip during initial-sync, manual local seek). Drop the `state.isPlaying` gate — the only signal that should matter is "drift between us and the host's reported state" — which `computeOrbitDriftMs` already computes correctly in the paused case (no time-extrapolation, just `guestPos - hostPos`). **2. Defer the play-state mirror after `player.seek`.** Reported symptom: "I press Catch Up, the player pauses; when I press play, it resumes from the *old* position and the waveform jumps back." Root cause: `player.seek` debounces `audio_seek` via `setTimeout(0)`, while `player.pause` / `player.resume` fire their invokes synchronously. Pause arrives at the engine first, leaving it paused at the pre-Catch-Up position with the seek queued behind. When resume happens later, the engine plays from the old spot. Push the play-state mirror behind a 200 ms `setTimeout` so the seek's invoke lands first. |
||
|
|
af1b9661f5 |
fix(orbit): three interlocking guest playback bugs (#525)
* fix(orbit): guest short-circuits queue-exhaustion fallback paths
When a guest's local queue runs out (single-track queue from `syncToHost`
empties on `audio:ended`), the player walks the standard fallback chain
in `next()`: radio top-up → infinite-queue → stop. The infinite-queue
branch builds a 6-track queue and calls `playTrack`, which trips
`orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal.
Hitting Cancel leaves playback frozen; "Add them all" injects unrelated
tracks into the host's shared queue.
In an active Orbit guest session the host owns the queue. Skip the
fallback paths entirely and just stop — the next `useOrbitGuest` pull
tick will sync to whatever the host advanced to.
Bonus side-effect: kills the deferred-promise race where a
`buildInfiniteQueueCandidates().then(...)` from a guest's track end
could resolve *after* a Catch Up replaced the queue and pop the modal
a second time against the now-current 1-track queue.
* fix(orbit): treat natural track-end as not-diverged in guest sync
When a guest's track ended naturally before the host advanced, the
divergence-detection branch read `player.isPlaying === false` and
classified it as the user manually paused — so it refused to load the
host's next track. The guest sat silent until they clicked Catch Up.
`handleAudioEnded` keeps `currentTrack` pinned to the just-ended track
and resets `currentTime` to 0, while a real manual pause leaves
`currentTime` somewhere mid-track. Use the 0-position discriminator to
classify natural-end as not-diverged so the host's new track loads.
Confirmed via the captured guest log buffer:
18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged
(player.isPlaying=false ≠ last.isPlaying=true)
— guest stuck for ~33s until Catch Up was pressed.
* fix(orbit): Catch Up polls until engine is ready before seeking
The 400 ms blind setTimeout in `onCatchUp` was too short for an
HTTP-streamed cold-start on high-latency links. If the audio engine
wasn't ready by then, `seek(fraction)` silently no-oped and playback
started at 0:00, making Catch Up effectively useless on exactly the
slow links where it's needed. Captured log shows a Catch Up bringing
the guest to posSec=30, then 5 s later the guest was at posSec=6
(playback restarted from the head).
Replace with the same poll-until-ready pattern `syncToHost` already
uses: check every 100 ms, fire the seek as soon as the engine reports
playing, fall back to a blind apply at the 4 s deadline.
* docs(changelog): add orbit guest playback fixes entry
* fix(orbit): debounce Catch Up button + match bar item height
Two follow-on UX fixes after PR #525's three primary bugs landed:
1. **Debounce visibility.** Drift is computed from an asymmetric signal:
guest's `currentTime` updates in coarse ~5 s chunks, while host's
position is extrapolated linearly via `(nowMs - posAt)`. Even on a
perfectly-synced session the diff swings ±5 s every tick, so the
button flickered in and out continuously. Show only after drift has
stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement
noise is filtered out, real sustained drift still surfaces in time.
2. **Match neighbour height.** The button was 32 px tall against 26 px
for the other action buttons (.orbit-bar__settings) so every flicker
shifted the entire bar height. Set `height: 26 px` and tighten the
padding/font so the layout is stable regardless of visibility.
* fix(orbit): tighten queue-extension lockout + reliable initial-sync seek
Two follow-on fixes after the 4-bug umbrella:
**1. Local queue-extension paths fully off during Orbit.**
Phase check broadened from `active` to cover `starting` / `joining` /
`active` so a fetch-then-join race can't pop the bulk-add modal *after*
the join. The proactive infinite-queue topper inside `next()` (which
fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each
async `.then()` callback in the radio + infinite-queue paths re-checks
at resolution time. A `playTrack(... 6-track queue ...)` after the user
joined Orbit was the path that re-triggered the "Add 5 tracks?" modal
on a freshly-joined guest.
**2. `syncToHost` only seeks once the engine reports playing.**
The previous 2 s deadline-fallback applied the seek even when the
engine hadn't started, where the seek silently no-ops and the track
plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 %
forward" — that's the seek finally landing because the engine is now
ready, the initial-sync seek had already failed silently. New deadline
is 5 s, and on timeout we return `false` so the outer pull tick keeps
`lastAppliedRef` null and the 500 ms fast-poll retries.
* fix(orbit): double-click play button + hide preview during session
Two cucadmuh-flagged gaps:
**1. Double-click on the inline play button now reaches the orbit-add
path.** The album-track row's onDoubleClick already routes to
`addTrackToOrbit` when in Orbit, but the inline play button stopped
propagation on click — so clicking it twice just fired the "double-
click to add" hint toast and never touched the orbit queue. Add an
onDoubleClick on the button itself that delegates to the parent's
`onDoubleClickSong`.
**2. Track preview is suppressed during an Orbit session.** Preview
shares the Rust audio engine with the shared playback, so starting
one as a guest yanks the host's track out from under everyone. A new
`[data-orbit-active]` attribute on `<html>` (set whenever role is
host/guest and phase is starting/joining/active) hides every
preview button via a single CSS rule, and `previewStore.startPreview`
short-circuits as a defensive guard for keyboard shortcuts and any
programmatic callers.
|
||
|
|
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 |
||
|
|
874b0c67ae |
fix(context-menu): drop inline z-index that hid menus under floating player (#522)
* fix(context-menu): drop inline z-index that hid menus under floating player
The main context menu wrapper carried an inline `zIndex: 999` that
overrode the `.context-menu { z-index: 10000 }` stylesheet rule. The
floating player bar sits at z-index 1000, so when a menu opened near
the bottom of the screen — long enough or anchored low — the player
bar covered it.
Removing the inline override lets the stylesheet rule (10000) win, so
the menu always paints on top of the floating bar. Submenus inherit
the same stacking context so they follow the parent menu.
Closes #521.
* docs(changelog): add context-menu floating-player z-index fix entry
|
||
|
|
fec513b629 |
fix(home): swap Because-you-listened rail to AlbumRow under 696 px (#520)
* fix(home): swap Because-you-listened rail to AlbumRow under 696 px The hero-style BecauseCards are tuned for full-rail widths (3 cards at 1052 px+, 2 cards at 696-1051 px). Below that the cards stretched full-width with a fixed 160 px cover stuck on the left and centred text floating in a wide empty area — looked like three over-sized banners stacked vertically instead of a compact recommendation rail. A `ResizeObserver` on the rail wrapper now watches the container width and below 696 px renders a standard `AlbumRow` (which is already perf-tuned for narrow rails: artwork budget, viewport windowing, scroll paging). Wide layouts keep the unchanged hero card layout, so the mainstage view at full width is identical to before. * docs(changelog): add Because-you-listened narrow-layout fix entry |
||
|
|
6f50fb6a19 |
feat(player-bar): album context menu on song title right-click (#512)
* feat(player-bar): album context menu on song title right-click Right-clicking the track title in the player bar now opens the same album context menu that album cards use (open, play next, enqueue, go to artist, favorite, rate, share, download, add to playlist). Mirrors the existing left-click behavior on the title, which already navigates to the album. Suppressed for radio and preview, matching the click handler. MarqueeText gains an optional onContextMenu prop; PlayerBar builds a SubsonicAlbum shape from currentTrack on demand. * docs(changelog): add entry for PR #512 (player-bar title context menu) |
||
|
|
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". |
||
|
|
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. |
||
|
|
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.
|
||
|
|
726f3f0ff2 |
fix(radio): queue navigation, dedup, and similar-first variety (#500) (#503)
* fix(radio): queue navigation, dedup, and similar-first variety (#500) After a Radio session ran a while, three things broke: Queue navigation through duplicates. playTrack re-resolved the active queue index by `findIndex(t.id === track.id)`, returning the *first* matching id, so reaching the second occurrence of a track snapped queueIndex back to the earlier slot — highlight jumped and the next advance played the wrong follow-up. Added an optional `targetQueueIndex` to playTrack, threaded through next(), previous(), the audio:ended repeat-one path, queue-row click, and the queue-item context menu. findIndex stays as the fallback for callers that just have a track and a fresh queue. Queue accumulation. enqueueRadio didn't dedupe incoming tracks; the next() top-up deduped against the live queue but trimmed the played tail down to HISTORY_KEEP=5, so a song heard 8 ago was gone from `existingIds` and a later Last.fm/topSongs response could re-add it; and the `.filter(...)` pass admitted intra-batch repeats (top + similar overlap is common) because it read the dedup set before mutating it. A module-level radioSessionSeenIds set, fed by enqueueRadio and both top-up paths and reset on artist change and clearQueue, closes all three: trimmed ids stay in the set, ids about to be replaced (fresh enqueueRadio wiping the pending radio block) are removed first so callers can re-introduce them, and the dedup pass mutates the set inline. Variety. Starting Radio on a track stacked five top tracks of the seed artist before any similar-artist material played. Switched the seed path and both top-up paths to lead with similar songs (other artists) and only fall back to top tracks when similar comes back empty — preserves the "no Last.fm" graceful degradation but stops the seed artist from monopolising the front of the queue. Not affected: gapless audio:track_switched (already index-based, no findIndex), AudioMuse Instant Mix / Lucky Mix (single-element queues or enqueue-only paths), the artist-radio path (no seedTrack — already picks just one top track and fills the rest from similar). Reported by netherguy4. * docs: changelog entry for PR #503 Logs the radio queue navigation/dedup/similar-first fix in v1.46.0 "## Fixed". |
||
|
|
ddb1f29af9 |
refactor(settings): remove redundant Animations 3-state setting (#495)
* refactor(settings): remove redundant Animations 3-state setting under Seekbar Style The `animationMode` setting (Full / Reduced / Static) duplicated work the perf-flag system and OS-level reduced-motion preference already covered: - `perfFlags.disableMarqueeScroll` already kills marquee scrolling on demand, replacing what `static` mode used to gate. - The `data-perf-disable-animations` html-level switch already strips every `*` animation, replacing what `static` mode used to do globally. - `@media (prefers-reduced-motion: reduce)` honours the OS setting for every user that asked for it via system preferences. - The 30 fps cap that `reduced` mode applied to the seekbar wave was better served by per-feature perf toggles cucadmuh added later. Removed: - `AnimationMode` type, `animationMode` field + setter from auth store. - Settings UI block (3 buttons + hint text) under Appearance > Seekbar Style. - `animationMode === 'static'` short-circuit in WaveformSeek's rAF effect; `isReduced` skip-every-other-frame logic; `static`-checks in `drawNow` / `needsDirectDraw`. - `animationMode !== 'static'` guard and `data-anim-mode` attribute in MarqueeText. - `[data-anim-mode="static"]` and `[data-anim-mode="reduced"]` rules in layout.css. - Seven i18n keys (animationMode + 6 variants) across all eight locales. Migration: the persist layer strips `animationMode` (and the legacy `reducedAnimations` boolean predecessor) so anyone who had `'reduced'` or `'static'` selected silently lands on the former `'full'` path on first launch after upgrade. No user-facing prompt — the missing setting just stops existing. cucadmuh's PR #472 (FPS overlay), #476 (preview-freeze main seekbar, sleep-recovery hooks, card-hover removal) and #486 (interpolation anchor reset on resume) are all preserved untouched — they live in separate effects / files and were not driven by `animationMode`. * docs(changelog): add Removed section for animationMode setting (PR #495) * docs(changelog): refine animationMode removal rationale (drop prefers-reduced-motion overstatement) |
||
|
|
ba73649360 |
fix(home): more variety + reliable render in Because-you-listened rail (#494)
Two reports rolled into one pass: 1. Rail occasionally rendered nothing on Home open. Cause: the rotated anchor sometimes had no Last.fm similar-artists or no library matches among the sampled set, and the old code gave up after one try and stored that dud anchor as the rotation cursor — so the next mount started from the same dud's neighbour and could fail again. 2. Recommendations felt repetitive. Cause: pool of 12 anchors walked round-robin made each anchor recur every 12 mounts, similar-artist sample of 6 from 12 had heavy overlap visit-to-visit, and per-artist single-album random pick meant artists with one library album always surfaced the same record. Anchor selection: random pick from pool with a per-server cooldown buffer (last 5 anchors excluded, capped at floor(pool/2) so small libraries don't soft-lock). Up to 4 anchors are tried in a shuffled candidates list before giving up; the localStorage cursor only advances on a successful anchor so duds don't poison future mounts. Picks variety: similar-artist fetch raised from 12 to 25 (same getArtistInfo call, larger response — Last.fm typically returns up to ~50). Per-server ring buffer of the last 30 shown album ids; per-similar -artist album choice prefers an album not in that buffer, falling back to any album when the artist's whole catalogue is stale so the slot is never lost. Pool cap raised 12 -> 20 to give the cooldown buffer room to breathe in libraries with varied listening history. Storage: legacy `psysonic_because_anchor:` single-id keys from the round-robin era are stripped on module load (one-shot localStorage sweep, the new `..._anchor_history:` prefix has a different colon position so no false matches). API budget unchanged in the hot path: 1 getArtistInfo + 6 getArtist per Home mount. Worst case (3 dud anchors, 4th succeeds) is 4 getArtistInfo + 6 getArtist. |
||
|
|
d75670ec4b |
feat(home): broaden Because-you-like seed pool + tidy orphan card at 1080p (#493)
* feat(home): mix recently-played + starred into Because-you-like anchor pool Anchor pool was sourced only from getAlbumList(frequent), so the rotation cursor walked the same eight top-played artists no matter how varied the rest of the listening history was. Round-robin merge of mostPlayed, recentlyPlayed and starred (dedup by artistId) means each mount can land on a different listening *mode* — heavy rotation, current focus, or explicit favorites — instead of stepping through the same top-played sequence. Pool size 8 -> 12 to let the cursor visit all three modes before wrapping. Visibility guard widened so the rail still renders when the server has no frequent-play data yet but starred or recent items exist. Zero new API calls — all three lists are already in Home's initial fetch. * fix(home): drop orphan 3rd Because-card in 2-col range, keep all 3 stacked on mobile auto-fit grid wraps to 2 cols between 696-1051px container width, which left the third card alone on a second row at 1080p. Container query hides the 3rd card only inside that 2-col band; on wider screens the full 3-up row stays, on narrow viewports (single column) all three cards stack vertically as expected. * docs(changelog): Because-you-listened seed pool + 1080p layout polish (PR #493) * docs(changelog): fold PR #493 refinements into the existing Because-you-listened entry Drop the separate Changed section entry — the feature is in the same 1.46.0 release window as PR #489, so readers want a single description of the final behaviour, not "added X, then changed X" for the same release. PR reference becomes "PRs #489, #493". |
||
|
|
b01e76df9c |
fix(home): Because-cards — readable layout at 1080p / 3-up (#492)
At 1080p with three cards per row (~370 px wide) the previous layout broke down: - 200×200 cover left only ~150 px of text width after gap + padding, so titles truncated mid-word and the meta-pill wrapped vertically into a stack instead of staying on one line. - The "·" separators in the meta vanished as soon as the pill wrapped, because the ::after lives inside the wrapping flex line. - Albums without cover-art rendered the placeholder as an empty grey rect — visually broken next to neighbours that did have art. Adjustments: - Cover wrap 200 → 160 px. Buys 40 px of text width per card and brings cover/text proportions into balance. - Meta-pill: inline-flex with width: max-content + max-width: 100% so the pill is content-fit when the meta line fits, capped at the available text width otherwise. Font 12 → 10 px, column-gap and padding tightened, flex-wrap kept (wraps to a second row if a server ever returns a really long meta string instead of clipping). - Cover-art placeholder shows a centred Lucide Music icon at 30 % --text-primary alpha — same visual weight as a faded thumbnail instead of an empty rect. |
||
|
|
f82f1be63a |
feat: redesigned community themes (#490)
* redesigned community themes * fixed select arrow obsidian-black & violet-haze * docs: CHANGELOG + Contributors entry for community themes redesign (PR #490) --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> |
||
|
|
d1ff2fab51 |
feat(home): Because you listened recommendation rail (#489)
* feat(home): "Because you listened" recommendation rail New Home rail (under Recently Added, default on, toggleable in Settings → Personalisation → Home Page) that surfaces 3 albums from artists similar to one of your top-played artists. Anchor rotates per Home mount so a different top-artist seeds the recommendations each visit; within each anchor, both the similar-artist subset and the chosen album per artist are randomised, so the same anchor returns different picks on subsequent visits. Card layout matches the regular Album cards' surface (--bg-card with accent-tinted border + 1px inset top highlight) and gets the same Play / Enqueue hover overlay buttons. Cover and meta scale via CSS only — no infinite animations, no filter/blur/transform, no compositing layers. Grid wraps below 3-up at <400px card width instead of shrinking. API budget: one getArtistInfo2 + 6 parallel getArtist calls per Home mount, both reusing the existing mostPlayed payload to derive the anchor pool (no extra API call to find top artists). All 8 locales seeded. * fix(home): ru plurals + per-server anchor + narrower card grid - ru: add _few / _many for becauseYouLikeTracks (CLDR Russian needs 4 forms — 3 треков was wrong, now 3 трека). - Anchor rotation memory is now per-server. The localStorage key becomes psysonic_because_anchor:<serverId>; switching servers no longer aliases server A's rotation onto server B's pool. - because-card grid minmax(400px, 1fr) -> minmax(340px, 1fr) so two cards fit side by side at typical sidebar-expanded widths instead of collapsing to a single card per row. * docs: CHANGELOG + Contributors entry for Because-you-listened rail (PR #489) * style(home): blurred cover backdrop + centred layout for Because-cards - Each Because-card renders the album cover as a blurred, low-opacity full-bleed background layer behind the existing cover thumb and text. Resolved through useCachedUrl so the cache layer feeds it (same key as the thumbnail) instead of a fresh salted URL on every render. - Card content (cover thumb + text block) now centred horizontally and vertically within the card; the meta line lives in a small pill that sits centred under the artist row. - Text contrast halo and meta-pill background are theme-aware via color-mix on var(--bg-card) / var(--text-primary), so the same rules read on dark and light themes (was hard-coded rgba black before and smudged the type on Latte / Nord Snowstorm). |
||
|
|
5b37ab70f1 |
perf(tracklist): drop now-playing pulse + EQ-bar animations (#488)
* perf(tracklist): replace animated EQ-bar + active-row pulse with static icon The currently-playing track in any tracklist (AlbumDetail, ArtistDetail, PlaylistDetail, Favorites, RandomMix) was rendered with two animations on top of an already-busy DOM: - `.track-row.active` ran `track-pulse` 3s opacity 1 → 0.6 → 1 infinite on the entire row subtree (title button, several Lucide icons, star rating SVGs, hover affordances). Opacity is a compositor property, but on WebKitGTK without compositing — Linux + NVIDIA proprietary + WEBKIT_DISABLE_COMPOSITING_MODE=1 — every animated row falls back to a full software repaint of the subtree per frame. - The "now playing" indicator in the track-number cell was three `<span class="eq-bar">` siblings with `transform: scaleY()` keyframes (`eq-bounce`), each on its own delay/duration. Same composite story: three software-repainted layers per frame, every frame. On AlbumDetail (long tracklist + cover-art header background + the already-running WaveformSeek progress rAF in the player bar) the combined cost held the WebProcess at ~80 % CPU with 1.2 GB RSS for the entire duration of playback. CPU dropped immediately on pause/stop; on Composers / Settings (no track rows) the symptom never appeared. Profiler confirmed continuous Layout & Rendering work synchronised with isPlaying, not the canvas itself. Replace both animations with static visuals: - `.track-row.active` keeps the `--accent-dim` background, drops the pulse animation entirely. - The "now playing" indicator becomes a single Lucide `AudioLines` icon (four vertical bars of different heights — reads as an EQ icon, no animation, one SVG per active row instead of three animated spans). `.eq-bars` className now only sets the accent colour. Cleanup: dead `@keyframes track-pulse`, `@keyframes eq-bounce`, `.eq-bars.paused` rule, plus a duplicate `.eq-bar` block in theme.css (with a `wave` keyframe that was being shadowed by components.css and had no other consumers). No behaviour change beyond removing the animation; the row is still visibly the active one and the icon still marks the playing track. * docs: CHANGELOG entry for tracklist animation perf fix (PR #488) |
||
|
|
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) |
||
|
|
c83447ebd2 |
fix(player): reset WaveformSeek interpolation anchor on resume (#486)
The interpolation effect unmounts while paused, so progressAnchorRef.atMs was never refreshed. The first tick after play added the entire pause duration to elapsedSec and overshot the playhead until the next transport heartbeat. Re-anchor from getPlaybackProgressSnapshot() when the effect starts. |
||
|
|
43d75e744b |
feat(selection): Shift+Click range selection on grid pages (#484)
* feat(selection): add useRangeSelection hook with Shift+Click range support
Reusable hook for multi-select state across pages that show grids of
items the user can pick. Tracks the selected ID set, a click anchor,
and exposes a `toggleSelect(id, { shiftKey })` callback:
- Plain click → toggles that item and moves the anchor to it.
- Shift-click on a second item → adds every item between the anchor
and the click target (inclusive) to the selection. The anchor moves
to the shift-clicked item so the next shift-click extends from
there.
Range expansion follows the items array passed to the hook, so the
caller controls the user-visible order (filtered + sorted list, not
the raw upstream array).
Implementation note: the anchor ref is snapshotted *before* the state
updater runs and written *after* it. React 18 strict mode invokes
state updater functions twice in dev to surface side effects, so any
ref mutation inside the updater would taint the second invocation and
the replay would miss the range branch.
* feat(selection): adopt Shift+Click range selection on grid pages
Wires `useRangeSelection` into the four pages that ship a multi-select
mode on top of card grids:
- Albums (passes the filtered/sorted `visibleAlbums` so range follows
the order the user actually sees)
- RandomAlbums
- NewReleases
- Playlists
`AlbumCard.onToggleSelect` is extended to forward `{ shiftKey }`, and
the card's onClick handler reads `e.shiftKey` from the React event
and threads it through. The Playlists grid uses an inline onClick on
the card div and was updated the same way.
User-visible behaviour: in selection mode, click an item then
shift-click a later item — every item between them gets selected.
Existing single-toggle behaviour is unchanged when no shift key is
held.
* docs: changelog entry for PR #484
Logs the Shift+Click range selection on grid pages under
v1.46.0 "## Changed".
|
||
|
|
ebce53f8a7 |
fix(sidebar): centre Playlists icon and unify hover hitbox in collapsed mode (#481)
* fix(sidebar): centre Playlists icon and unify hover hitbox in collapsed mode The Playlists nav entry had its own special render path with a wrapper div, header-row and `flex: 1` main link to fit the expand-toggle button. Those elements remained active in collapsed mode too, where `padding-right` on the header-row and `flex: 1` on the link made the icon sit off-centre and gave the row a wider hover hitbox than every other collapsed sidebar item. Hoist the `isCollapsed` check above the playlists special-case so that in collapsed mode Playlists renders through the same plain `<NavLink>` branch as Artists / Albums / Favorites / etc. The expanded-mode treatment (wrapper, header-row, expand-toggle, nested playlist list) is unchanged. * docs: changelog entry for PR #481 Logs the collapsed-sidebar Playlists icon centring fix in v1.46.0 "## Fixed". |
||
|
|
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). |
||
|
|
d48ea819c1 |
fix: stabilize preview seekbar, post-sleep audio recovery, and card hover behavior (#476)
* fix(player): freeze main seekbar during track preview Preview pauses the main sink in Rust while isPlaying stays true in the store, so WaveformSeek's interpolation rAF must not advance progress. * fix(audio): recover output after sleep and stalled streams Add platform-specific post-sleep recovery hooks for Windows and Linux, and add a watchdog that reopens the output stream when playback is active but sample progress stalls, so audio can recover without restarting the app. * fix(ui): remove card hover lift and smooth artwork zoom Remove vertical hover translation from album and artist cards, and move image fade transition out of inline styles so cover zoom uses CSS timing consistently. * fix(player): prevent seekbar jump after preview ends Reset interpolation anchor timing when preview freeze state changes so the main seekbar does not momentarily jump forward before resyncing. * fix(audio): reduce false watchdog recoveries and add diagnostics Arm stalled-output recovery only after long poll gaps that suggest sleep/resume, and add detailed watcher logs for arm/clear/trigger paths to diagnose unintended stream reopens. * chore(ui): drop card GPU hints and clarify macOS sleep scope Remove translateZ and will-change hints from album and artist cover images to avoid per-card compositing overhead on software-composited Linux paths, and document why post-sleep recovery hooks currently target only Windows and Linux. * docs(audio): document intentional Win32 callback pointer lifetime Add inline rationale for the two Box::into_raw pointers in Windows suspend/resume registration so future maintenance does not treat the process-lifetime pointers as accidental leaks. * docs(changelog): summarize playback stability updates for PR #476 Add a high-level changelog entry for preview seekbar fixes, sleep/wake audio recovery hooks and watchdog diagnostics, and card-hover stability adjustments from PR #476. * docs(contributors): add cucadmuh entry for PR #476 Logs the post-sleep audio recovery, preview-seekbar fixes and card hover stability work in the Settings → System → Contributors list. |
||
|
|
5abe18d5b8 |
Perf/UI performance (#473)
* perf(ui): defer off-screen Artist grid paint + rAF overlay scrollbar Apply content-visibility to artist tiles in .album-grid-wrap (aligned with album cards). Coalesce OverlayScrollArea thumb updates to one requestAnimationFrame per scroll burst. * perf(css): content-visibility on horizontal artist rails Apply the same off-screen deferral as album cards to `.album-grid .artist-card` (ArtistRow, Favorites, etc.). |
||
|
|
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. |
||
|
|
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. |
||
|
|
072bef473f |
fix(queue): restore pre-#419 Play-icon prefix on the active row (#469)
PR #419 replaced the small (10px) Play icon next to the active queue title with an animated 3-bar eq-bars block prefixed before the row. Restore the original Play-icon-in-title behaviour and drop the unused isStorePlaying selector. |
||
|
|
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. |
||
|
|
d33abf565c |
feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch (#466)
* feat(ui): StarFilterButton component + common i18n keys Reusable toggle button for "favorites only" filtering. Three size variants for different toolbar contexts: - default: icon + label (Albums-style) - compact: icon-only with 0.5rem padding (Artists view-mode buttons) - small: icon + label at 12px / 4×14 padding (AdvancedSearch tabs) Adds common.favorites + favoritesTooltipOff/On in all 8 locales. * feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch Client-side filter using the existing useMemo pipelines on each page. Reads starred state from item.starred + playerStore.starredOverrides (O(1) Map lookup, picks up live star toggles without refetch). - Albums: toolbar button (default size) next to compilation filter. - Artists: toolbar button (compact / icon-only) before the Images toggle. - AdvancedSearch: toolbar button (small) next to the result-type tabs; filters all three result categories (artists / albums / songs) and updates the count badges accordingly. Filter state is ephemeral per-page (not persisted) so users don't get surprised by hidden items after a restart. Zero extra server calls. * docs(contributors): credit + changelog entry for #466 |