Commit Graph

70 Commits

Author SHA1 Message Date
Psychotoxical 5fdec6cd2a refactor(genre): co-locate genre browse pages into features/genre 2026-06-30 17:59:06 +02:00
Psychotoxical 5808ef0839 refactor(folderBrowser): co-locate folder-browser feature into features/folderBrowser 2026-06-30 17:57:17 +02:00
Psychotoxical ac6974d4f1 refactor(album): co-locate NewReleases + MostPlayed browse pages into features/album 2026-06-30 17:54:25 +02:00
Psychotoxical 2756cb7698 refactor(lib): relocate the local-library subsystem utils/library -> lib/library
utils/library is the local-index query/browse/sync engine -- feature-free shared
infra consumed by the album, search, genre, artist and offline features. With
its last feature edges removed (queue resolver, shuffleArray, albumBrowseCatalogChunk,
genreBrowsePlayback all relocated earlier this branch), the whole directory is
pure infra and belongs under lib/.

Whole-directory move (58 files, 76 consumers rewritten) via resolver-based
rewrite: cross-cluster relatives absolutized to @/, intra-cluster siblings kept
relative. utils/library is gone; lib/library imports zero @/features. Three
stale doc comments in the album/search/artist barrels repointed.

tsc 0, lint 0, full suite 2353/2353, production build OK.
2026-06-30 17:06:03 +02:00
Psychotoxical f41005682d refactor(playback): move thin-state queue resolver out of utils/library into the feature
The queue-resolver family (queueTrackResolver, queueRestore, queueItemRef,
queueTrackView) runtime-imports usePlayerStore / playerStoreTypes — it is the
playback engine's thin-state queue subsystem, not shared library infra. It sat
in utils/library only because it queries the local index. Co-locate into
features/playback/store so those edges become intra-feature; its remaining deps
(advancedSearchLocal, libraryReady, serverLookup, serverIndexKey, authStore) are
plain core/infra (feature -> infra, no inversion).

Removes 4 of the 6 store/utils -> feature runtime inversions that blocked the
utils/library -> lib move. 8 files moved, 46 consumers rewritten. tsc 0, lint 0,
targeted suites green.
2026-06-30 16:50:29 +02:00
Psychotoxical cb1a110afb refactor(playback): move the audio engine into features/playback
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
  store/<x>                    -> features/playback/store/<x>
  store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
  utils/playback/<x>           -> features/playback/utils/playback/<x>
  utils/audio/<x>              -> features/playback/utils/audio/<x>

184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).

KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).

Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).

tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
2026-06-30 15:00:20 +02:00
Psychotoxical 6651abbc6f refactor(decouple): playback-engine bridge — break authStore→engine edges
The last core→engine inversions blocking the playback-core move live in the
authStore settings/profile family: authServerProfileActions reads
usePlayerStore.queueServerId + calls clearQueueServerForPlayback (on server
delete), and authAudioSettingsActions calls
usePlayerStore.updateReplayGainForCurrentTrack from ~8 ReplayGain/normalization
setters. Both are re-exported through the authStore barrel, so a global store
would depend on the (soon-to-move) engine.

Add core seam store/playbackEngineBridge.ts: a registry exposing getQueueServerId
/ clearQueueServerForPlayback / updateReplayGainForCurrentTrack with no-op/null
defaults. The engine registers its impls via store/playbackEngineBridgeRegister.ts
(side-effect-imported by MainApp at boot). authStore actions now call the neutral
delegators instead of importing the engine.

Default is safe: the only callers are user-triggered settings/profile actions that
fire long after boot (engine already registered); at boot there's no current track
or queue binding, so the no-op would be correct anyway. authStore.servers.test
imports the register module so removeServer's queue-clear runs through the real
engine wiring.

Prepares the playback-core move (Engine → features/playback): with this + keeping
the 3 config-helper utils in utils/ + tolerating 2 type-only edges, the engine has
no inbound core→feature inversions left. tsc 0, lint 0, suite 319/2353 green.
Behavior-touching (server-delete queue clear, settings→gain refresh) → Frank QA.
2026-06-30 14:45:34 +02:00
Psychotoxical d5bbabac1d refactor(queue): co-locate QueuePanel UI into features/queue
Extract the queue UI (QueuePanel + queuePanel/* components + useQueue* hooks)
into features/queue/ with a barrel. This is the chosen playback/queue boundary:
queue = the QueuePanel UI that CONSUMES the playback store; queue STATE and the
audio engine stay in store/ (playback-core, moved separately). Verified
one-way: no playback-core file imports the queue UI back (only doc comments
mention it).

Excluded as NOT queue-UI: useIdlePlayQueuePull (AppShell) and
usePlayQueueSyncLedState (ConnectionIndicator) are queue-SYNC orchestration,
not panel UI — left in hooks/ to move with playback.

Pure move. One test fix: QueuePanel.test.tsx reads its subject via a hardcoded
readFileSync('src/components/QueuePanel.tsx') path (architecture-pin grep for
forbidden HTML5 DnD) — repointed to the new location (the move tool can't
rewrite non-import string paths). tsc 0, lint 0/0, suite 319/2353 green.
2026-06-30 08:40:51 +02:00
Psychotoxical 7ad196711e refactor(lib): move generic hooks, DnD engine, shortcut contract to lib
Continue M4 lib/ de-flattening with domain-agnostic infra:
- lib/hooks/: 9 pure generic hooks (useDebouncedValue, useIsMobile,
  useLongPressAction, useRangeSelection, useResizeClientHeight,
  useWindowVisibility, useSystemPrefersDark, useVirtualizerScrollMargin,
  useRemeasureGridVirtualizer) — all PURE (only react/zustand/tanstack deps).
- lib/dnd/DragDropContext.tsx: the generic mouse-event DnD engine (WebKitGTK
  HTML5-DnD workaround, ~24 importers, self-contained) — empties src/contexts/.
- lib/shortcuts/: shortcutActions + shortcutTypes (the action-id + binding
  contract; registry/dispatch/bindings stay app-level and import the contract,
  app->lib direction).

useWindowFullscreenState deliberately NOT moved — kept in hooks/ as app-shell
per the handoff iron-rule list (overrides its pure-helper appearance).

Pure move via deep @/lib/* specifiers. tsc 0, lint 0/0, full suite 319 files /
2353 tests green.
2026-06-30 08:20:11 +02:00
Psychotoxical 209dd61442 refactor(lib): consolidate generic infra into src/lib
Move domain-agnostic, feature-free helpers out of the flat utils/ and store/
roots into src/lib/ (plan M4, §3 lib/ layer):
- lib/format/: formatBytes, formatClockTime, formatDuration, formatHumanDuration,
  relativeTime (pure format/date/byte/clock helpers)
- lib/i18n.ts: i18next bootstrap (global app infra)
- lib/util/: sanitizeHtml, platform, dedupeById, safeStorage (pure helpers +
  the zustand storage adapter)

playbackScheduleFormat stays in utils/format (runtime usePlayerStore dep =
audio-core coupling, not generic). formatClockTime keeps a type-only
@/store/authStoreTypes import (erased, no runtime inversion — accepted per the
deviceSync precedent).

Pure move via deep @/lib/* specifiers (no barrel → no mock-collapse surface).
tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: lib_move.py regex extended to also rewrite side-effect imports
(import './i18n') and vi.importActual paths — both were silent gaps.
2026-06-30 08:07:04 +02:00
Psychotoxical 862941c145 refactor(playlist): co-locate playlist feature into features/playlist 2026-06-30 01:37:05 +02:00
Psychotoxical 2a425862ae refactor(album): co-locate album feature into features/album 2026-06-30 01:33:29 +02:00
Psychotoxical 9ffc42688c refactor(artist): co-locate artist feature into features/artist 2026-06-30 01:27:01 +02:00
Psychotoxical 321d5ff6ab refactor(orbit): co-locate orbit feature into features/orbit 2026-06-30 01:02:58 +02:00
Psychotoxical d6dbb615fd refactor(offline): co-locate offline feature into features/offline 2026-06-30 00:56:08 +02:00
Psychotoxical e945da693a refactor(settings): co-locate settings feature into features/settings
Theme infra (fixedThemes, theme utils), music-network presetIcon, audio-device
probe, library-index sync, and the auth*SettingsActions slices stay in the
core/global layer (consumed via @/ alias) to avoid inverting core->feature.
2026-06-29 23:48:19 +02:00
Psychotoxical fbc37db64e refactor(search): co-locate search feature into features/search 2026-06-29 23:42:22 +02:00
Psychotoxical 931c47e19e refactor(nowPlaying): co-locate now playing feature into features/nowPlaying 2026-06-29 23:38:09 +02:00
Psychotoxical e0e10e0034 refactor(sidebar): co-locate sidebar feature into features/sidebar 2026-06-29 23:26:00 +02:00
Psychotoxical e6269c7b85 refactor(miniPlayer): co-locate mini player feature into features/miniPlayer 2026-06-29 23:22:00 +02:00
Psychotoxical 6dd0d4a130 refactor(fullscreenPlayer): co-locate fullscreen player feature into features/fullscreenPlayer 2026-06-29 23:17:14 +02:00
Psychotoxical 896fe3f407 refactor(radio): co-locate internet radio feature into features/radio 2026-06-29 23:04:34 +02:00
Psychotoxical dbffe59e58 refactor(favorites): co-locate favorites feature into features/favorites 2026-06-29 22:59:21 +02:00
Psychotoxical ea3e3f1adf refactor(deviceSync): co-locate device sync feature into features/deviceSync 2026-06-29 22:55:35 +02:00
Psychotoxical 236acc33dd refactor(stats): co-locate statistics feature into features/stats 2026-06-29 22:51:54 +02:00
Psychotoxical bd2d669b99 refactor(ui): co-locate OverlayScrollArea as a shared primitive in src/ui 2026-06-29 22:35:41 +02:00
Psychotoxical 146727e980 refactor(ui): co-locate TooltipPortal and tooltipAttrs as shared primitives in src/ui 2026-06-29 22:35:14 +02:00
Psychotoxical ed02ba4e1d feat(titlebar): theme-colored window bar on macOS (#1199)
* feat(titlebar): theme-colored window bar on macOS

macOS showed the grey native title bar that ignores the app theme
(#1198). Enable titleBarStyle: Overlay so the webview reaches the top
edge and the native traffic lights float over the existing in-page
title bar, which already paints with the active theme.

Reuses the Linux title-bar grid: the bar is shown on macOS too (hidden
in native fullscreen), without the custom window buttons since the
traffic lights stay native. The GTK resize grips are now Linux-only.
Renamed the stylesheet to drop the misleading linux-only name.

* docs(changelog): macOS themed title bar

* fix(titlebar): crisp macOS bar text — drop transform layer and shadow

* fix(titlebar): drop now-playing text from the macOS bar
2026-06-27 14:17:55 +02:00
Psychotoxical 014d57c53c feat(app): top-level error boundary so a render error no longer blanks the app (#1194)
* feat(app): add a top-level error boundary so a render error no longer blanks the app

Until now any thrown error during render took the whole window down with no recovery (issue #382). A class ErrorBoundary around the authenticated shell catches it and shows a recoverable fallback (Try again / Reload app) while playback keeps going. Hook-free + English-only with literal CSS var() fallbacks so it renders even when i18n/theme/state is what broke.

* docs(changelog): error boundary prevents the app blanking on a render error (PR #1194)
2026-06-26 20:51:42 +02:00
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs

* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)

* chore(eslint): clear unused vars in config, contexts, app and test

* chore(eslint): clear unused vars in api and music-network

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

* chore(eslint): clear unused vars in cover and hooks

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

* chore(eslint): align react-hooks exhaustive-deps

* chore(eslint): zero gradual config on src

* chore(eslint): strict hook rules in store and utils

* chore(eslint): strict hook rules in hooks and cover

* chore(eslint): strict hook rules in components

* chore(eslint): strict hook rules in pages and contexts

* chore(eslint): document scripts ignore in eslint config

* chore(eslint): add lint script and zero strict findings

* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)

* chore(eslint): tighten two set-state disable comments (review round 2 LOW)

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

* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 01:33:34 +02:00
Psychotoxical c7d76af790 feat(themes): add follow-system mode to the theme scheduler (#1163)
* feat(themes): add follow-system mode to the theme scheduler

The theme scheduler can now switch the day/night theme pair by the OS
light/dark preference instead of a clock schedule. A segmented control
picks the trigger; in system mode the time inputs are hidden and the two
theme pickers read as Light/Dark.

The OS theme is read via the native Tauri window theme API (theme() +
onThemeChanged) rather than the Web prefers-color-scheme media query,
which is unreliable through WebKitGTK on Linux. Live updates land
instantly where the platform forwards them; on Linux setups that don't,
a hint notes the change applies after an app restart.

* docs(changelog): add follow-system theme mode entry (#1163)

CHANGELOG Added entry, contributor credit and What's New highlight for the
theme scheduler's new system-theme mode.
2026-06-23 12:13:26 +02:00
cucadmuh 955a9fcbd6 feat(queue): play queue sync — manual pull, idle auto-sync, multi-server push (#1131) 2026-06-19 18:19:11 +03:00
Psychotoxical 1a82376f8c feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts

Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.

* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports

Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:

- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
  (Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON

lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.

* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets

Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).

- client.ts: transport wrapper over audioscrobbler_request, classifying
  failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
  token-poll presets (both endpoints verified live)

Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.

* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets

Add the scrobble-destination wires and presets:

- ListenBrainz wire (scrobble + now playing via playing_now), backing both
  the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
  compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
  no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
  the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
  (scrobble-only, session-key paste, bundled keys — verified live), and
  custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix

maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.

* feat(music-network): registry, orchestrator, enrichment router + runtime facade

Wire the framework together behind a single facade:

- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
  presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
  final authority (lets two presets on one wire diverge, e.g. Rocksky's
  nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
  session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
  type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
  enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
  port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel

Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.

* feat(music-network): auth-store state + lossless legacy migration + runtime bridge

Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.

- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
  scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
  username and scrobbling preference; fills bundled Last.fm key from the preset;
  sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
  disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
  the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap

nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.

* feat(music-network): route playback, enrichment and love through the runtime

Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:

- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
  via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
  playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
  nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
  the ArtistDetail similar-artists gate now use the runtime, gated on an
  enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
  the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
  lastfmActions -> networkLoveActions; loved cache storage renamed with a
  lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
  throw) before the runtime is initialized

src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.

* feat(music-network): manifest-driven Integrations UI + scrobble batch format

Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.

- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
  cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
  driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
  Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
  Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
  single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
  code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey

Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).

* fix(music-network): clearer Integrations layout + scrobble batch fix

Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
  row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
  not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
  liked tracks/similar artists/stats come from it while scrobbling still goes
  to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)

Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.

* feat(music-network): indicator + remove legacy lastfm path

Phase 7b/7c — finish the cutover and delete the old Last.fm path.

- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
  status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
  authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
  fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
  enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
  ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
  tests; update settingsTabs + src/CLAUDE.md

No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.

* test(music-network): cover scrobble shape + error classification, drop dead i18n keys

Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.

Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.

* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets

- de-hardcode the single provider name across every enrichment surface
  (love labels, now-playing badge, stats title); derive it from the
  enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
  'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
  mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
  lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry

* fix(music-network): validate paste-auth keys on connect + UI polish

- AudioscrobblerWire.probe now validates an api_key_only session with a
  signed call and reports scrobble:'error' only on a genuine auth failure
  (a scrobble-only service that rejects user.getInfo is not a bad key), so
  an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
  instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
  lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
  neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour

* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments

Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.

* docs(changelog): Music Network entry (PR #1066)

Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>

* fix(music-network): bound request timeout on the provider transports

audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.

* refactor(music-network): dedupe wire transport + no-enrichment helpers

The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.

* refactor(music-network): drop write-only malojaWireMode dead state

malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.

* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback

The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.

---------

Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
2026-06-11 22:56:30 +02:00
cucadmuh 5cd01c90ac fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill

Restore atomic genre browse, filters, and counts via track_genre:
OpenSubsonic genres[] first with Navidrome-default split fallback, sync
write path, read-path query switches, blocking startup backfill with
progress, and v12 repair migration for DBs that recorded legacy 002–011.
TS fallback adds genreTagsFor and migration gate i18n across locales.

* fix(library): address multi-genre review — robust TS genres and scope join

genreTagsFor routes raw genres through parseItemGenres (single-object
Subsonic quirk and bare strings). Library-scoped genre browse/counts join
track for raw_json library_id fallback. Statistics keeps empty-genre bucket.

* docs: CHANGELOG and credits for multi-genre local index (PR #1059)

* docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
2026-06-11 01:02:35 +03:00
cucadmuh c7d71ea57c feat(whats-new): remote release notes with dev workspace mode (#1058)
* feat(whats-new): remote release notes with dev workspace mode

Add WHATS_NEW.md, CI whats-new.md asset upload, and client fetch/cache
with embedded fallbacks. Dev and -dev builds read the full file from the
repo for debugging; RC/stable download the release asset on first use.

* fix(whats-new): render ## headings and add changelog tab

Parse h2 sections in release-notes markdown; load changelog alongside
highlights and let users switch views on the What's New page.

* fix(whats-new): prefetch on startup and fix CI typecheck prebuild

Prefetch whats-new asset when the shell loads on RC/stable builds.
Run prebuild:release-notes before tsc and coverage jobs so the
gitignored generated bundle exists in CI.

* docs: CHANGELOG and credits for What's New remote notes (PR #1058)

* fix(whats-new): always slice embedded release notes to current line

Drop full CHANGELOG embed for -dev bundles; tauri:dev still reads live
markdown from the repo. Ignore all of src/generated/ in git.

* fix(whats-new): fetch release asset via Rust to bypass CORS

Route whats-new.md download through fetch_url_bytes; rename the
technical tab label; add fetch unit tests (PR #1058 review).
2026-06-10 23:35:23 +03:00
cucadmuh 5167d8f49e feat(ui): themed startup splash with deferred window show (#1030)
* feat(ui): add themed startup splash with deferred window show

Show a theme-aware loading splash before the Vite bundle mounts, hide the
native window until it paints, and use per-theme logo gradient colors.

* docs: note startup splash in CHANGELOG and credits for PR #1030

* docs: attribute PR #1030 startup splash to cucadmuh
2026-06-08 12:59:07 +03:00
cucadmuh fc34a0ec59 feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums

Make Artists, All Albums, and artist/album detail pages work offline
from the library index limited to on-disk library and favorite-auto
tracks. Add a DEV header toggle to simulate offline browse for testing.

* feat(offline): reactive DEV offline toggle with full disconnect simulation

Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI
refreshes on toggle. DEV force-offline now blocks server probes, reports
disconnected status, and gates Subsonic like real offline for player parity.

* feat(offline): bytes-first favorites when offline browse is active

Load Favorites from local playback bytes, filter starred tracks client-side,
and restrict album-level star queries to local album ids. Drop interim perf
attempts (lean SQL, progressive load, connection singleton, prefetch UX).

* feat(offline): tracks, help, player stats; suspend library picker offline

- Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics
- Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches
- Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected)
- Unified isOfflineSidebarNavAllowed for library + system entries

* feat(offline): fork disconnect navigation by offline browse capability

When the server drops: stay on the page if nothing is browsable offline;
reload in place on offline-capable routes; otherwise redirect to All Albums
instead of the old /offline or /favorites bounce.

* feat(offline): browse cached playlists when the server is down

List and open manually pinned regular playlists from local library-tier
bytes offline, with sidebar/nav routing and read-only playlist UI.

* feat(offline): read-only artist detail and local play-all paths

Hide favorites and discography offline actions when browse is offline;
load Play All, Shuffle, and top-track continuation from local album bytes.

* feat(offline): read-only album detail and enqueue from local bytes

Hide favorites, download, and cache-offline actions on album pages when
offline browse is active. Favorites album cards enqueue via the same
resolveAlbumForServer path as play, including local playback bytes.

* chore: remove unused import in AlbumCard after enqueue refactor

* feat(offline): unify browse integration contract across the app

Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy;
wire shell nav to a single capability source; migrate play/enqueue and
context-menu paths off raw getAlbum; replace readOnly with action policy
on detail surfaces. Tests updated for the media-resolve facade.

* feat(offline): close browse contract gaps and fix offline Home feed

Split offline browse modules, align favorites capability across servers,
wire action policy on context menus, migrate hooks to useOfflineBrowseContext,
and preserve stale Home feed cache when offline so the UI does not empty.

* fix(offline): block playbar stars, close audit gaps, trim dead exports

Hide star rating and favorite in PlayerBar when offline browse is active via
offlineActionPolicy playerBar surface. Wire stay-reload token into browse
hooks, migrate hooks to context.active, guard rating prefetch network calls,
and route playlist load through resolvePlaylist.

* docs: add CHANGELOG and credits for offline browse PR #1017

* fix(offline): stop DEV connection probe regression in tests

React to devForceOffline transitions only in useConnectionStatus so mount
does not double-fire check() or ignore disableBackgroundPolling. Add
pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
2026-06-07 15:59:41 +03:00
Psychotoxical 23f032b274 feat(themes): free-form community themes with a security floor (#1015)
* feat(themes): free-form community themes with a security floor

Community themes are no longer token-only. The in-app guard
(validateThemeCss) now enforces only a security floor — no network
(@import / non-data url()), no scripts (<style>/<script>/expression()/
javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-,
and a 256 KB cap — and otherwise allows any selectors, structure and
animations. validateThemePackage checks the manifest plus that floor.

Themes can react to app state via same-element attributes set on the theme
root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open.

The local-import confirm dialog now notes that imported themes aren't
reviewed and are installed at the user's own risk. Removes the now-unused
bundled token whitelist.

* docs(themes): note free-form themes in the Theme Store entry

Add PR #1015 and a free-form bullet to the still-unreleased Theme Store
changelog and credits entry.
2026-06-07 14:04:38 +02:00
Psychotoxical f9df918c72 feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1)

Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and
--text-on-accent on the :root base as --ctp-* mappings. They resolve
per-theme automatically and nothing consumes them yet (zero behaviour
change) — groundwork for replacing direct --ctp-* use in components.

* refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2)

Replace every direct --ctp-* reference in component/layout/track CSS and
TSX inline styles with the readable semantic token (--bg-app, --accent,
--highlight, --text-on-accent, …). --ctp-* now survives only as the
Catppuccin palette layer the base maps from, and as the deliberate
categorical rainbow in Composers/Genres/artistsHelpers (left untouched).

This is the readable contract surface for the community theme store.
Divergences (theme set a semantic var != its --ctp- source) are
corrections — the element now uses the theme's real semantic colour.

* feat(themes): add player-bar title/artist color tokens

New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token.

* refactor(themes): token-only theme library (flatten, whitelist, one file per theme)

Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer):

- Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens.

- Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents.

- Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging.

Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins.

* chore(themes): remove seven themes retired after the token refactor

These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry.

* feat(themes): granular tokens — track lists

Wire track rows to per-region tokens: row hover (--row-hover), the now-playing
row + indicator (--row-playing-bg / --row-playing-text), track title/artist/
number/duration text, column-header text, row dividers, and the resize-handle
active colour. Covers the desktop tracklist, the shared song-row (Tracks hub /
search), and the mobile tracklist. Drop a baked border fallback. Visual no-op.

* feat(themes): granular tokens — cards

Wire album and artist cards to per-region tokens (--card-hover-border,
--card-title, --card-subtitle, --card-placeholder-bg). Visual no-op.

* fix(themes): drop undefined/baked colour aliases

Replace the undefined --bg-surface (resolved to nothing — broken placeholder
backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the
baked-hex aliases --color-error / --color-warning with --danger / --warning so
themes can actually recolour them.

* feat(themes): Spectrum demo theme + trim unused cascade tokens

Add a loud built-in demo theme that gives each region its own hue (sidebar
green, player pink, lists cyan, cards gold, menus red, controls blue) so the
per-region granularity is obvious when you switch to it. Drop two unused
cascade tokens (--sidebar-text-active, --row-active-bg) and the unused
on-media block (those media surfaces stay static by design).

* style(themes): make Spectrum demo brutally loud

Full-saturation neon per region (toxic green sidebar, magenta player, cyan
lists, acid-yellow cards, blood-red menus, electric-blue controls, purple
scrollbar) so the per-region separation is unmistakable. The earlier soft
tints were too subtle to read.

* feat(themes): name the granular demo theme Braindead

* feat(themes): granular per-region tokens — cascade layer + sidebar

Add an optional per-region token layer (semantic-cascade.css) so a theme can
recolour individual regions — sidebar hover, player controls, list rows,
menus, inputs, on-media surfaces — independently of the global tokens. Every
token defaults to its base token (or a media-safe literal), so this is a
visual no-op until a theme overrides one; it only adds control points.

Wire the sidebar region as the first consumer and drop the baked grey
fallbacks (--bg-tertiary, etc.) that no theme could reach.

* feat(themes): granular tokens — controls, menus, scrollbar

Wire inputs, buttons, sliders, the custom-select, context menus, submenus and
modals to per-region tokens, and tokenise the scrollbar. Complete B0 by
dropping the last direct --ctp-* references in the input/button/progress/
scrollbar utility CSS.

Fix three undefined-token bugs that fell back to nothing (so no theme could
reach them): --surface-2 (context-menu hover had no highlight), --bg-surface
(submenu create-input had no background), and the baked grey fallbacks in the
custom-select. Every other new token defaults to today's value — a visual
no-op that only adds override points.

* feat(themes): granular tokens — player bar

Wire the desktop player bar's transport controls, time toggle, and overflow
menu to per-region tokens (--player-control, --player-time-toggle-*, etc.).
Fix the undefined --surface-hover/--surface-active grey fallbacks on the time
toggle. Visual no-op; defaults match today's values.

* chore(themes): remove empty theme stub files

Six theme CSS files were reduced to comment-only stubs by the flatten
sweep but their files and @import lines remained. Five are empty
structural companions of now-flattened themes (morpheus, p-dvd,
aero-glass, luna-teal) and two are orphans of cut themes
(order-of-the-phoenix, pandora). Removed the files and their imports.

* feat(themes): runtime injection foundation for the theme store

Plumbing for installed community themes ahead of the in-app store UI,
nothing user-visible yet:

- installedThemesStore: persisted (localStorage) record of installed
  community themes incl. their CSS text, so an active community theme is
  available synchronously at startup (no flash, fully offline).
- themeInjection: reconcile <head> <style data-installed-theme> elements
  with the store; lightweight defense-in-depth sanitize on top of CI.
- themeRegistry: jsDelivr registry client with a 12h localStorage cache
  and stale-on-error fallback.
- App: inject installed themes before applying data-theme, in both webviews.
- themeStore: widen the Theme type to accept dynamic installed ids.

* feat(themes): dedicated Themes settings tab

Move theme selection and the day/night scheduler out of Appearance into
a new dedicated Themes tab — the future home of the community Theme Store.
Appearance keeps grid columns, visual options, UI scale, font and seekbar.

- ThemesTab: theme picker + scheduler (relocated verbatim).
- AppearanceTab: drop the two relocated sections + now-unused imports.
- Register the tab in settingsTabs (Tab union, resolveTab, search index)
  and Settings (tab bar, render, label map).
- i18n: settings.tabThemes in all 9 locales.

* feat(themes): community Theme Store browse + install

Add the Theme Store section to the Themes tab:

- Fetch the jsDelivr registry (12h cache, stale-on-error fallback).
- Search by name/author/description + filter by light/dark + refresh.
- Per-row CDN thumbnail, name, author, description and actions:
  Install / Apply / Update / Uninstall. Installing fetches the CSS,
  persists it (localStorage) and the runtime injection applies it;
  uninstalling the active theme falls back to the matching core.
- Rating slot left reserved (deferred).
- i18n: themeStore* keys in all 9 locales.

* feat(themes): slim bundle to fixed cores + flat Themes tab

Remove the 86 store palettes (incl. braindead) from the app bundle — the
CSS files, their index.css imports, the Theme union and the picker data —
leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave,
Stark HUD, Vision Dark/Navy). Everything else installs from the store.

Themes tab is rebuilt flat (no collapsible accordions):
- "Your Themes": one card grid of the fixed cores + installed community
  themes; click to apply, uninstall on community ones (active theme falls
  back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe
  pill on the colour-blind-safe Vision themes.
- Scheduler day/night options include installed themes.
- Theme Store: alphabetical order, thumbnail lightbox, and a submit hint
  above the search linking to the themes repository.
- Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, …
- ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts.
- i18n for all new strings across 9 locales.

* feat(themes): reset removed-from-bundle themes to a bundled fallback

After slimming the bundle to the six fixed core themes, a profile upgraded
from an older build may have an active or scheduler theme that is now
store-only and not installed — it has no [data-theme] block and would render
as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled
nor installed to a bundled fallback: Mocha for the main + night slots, Latte
for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the
persisted selection in localStorage before React mounts (no flash; Zustand
rehydrates after first paint). No auto-install and no network — the fallback
is always a bundled theme, so it works offline.

* feat(themes): floating back-to-top button on the Themes tab

The Themes tab can get long (theme grid + scheduler + full store list), so
add a floating back-to-top affordance that appears once the page is scrolled
and smooth-scrolls to the top. It is portalled into the route host and
positioned absolute against it — the main scroll viewport sets contain: paint,
which would otherwise make position: fixed resolve against the scrolling box
and drift with the content. Reusable component (scroll viewport id + threshold
props); i18n common.backToTop added in all nine locales.

* feat(themes): accessibility + state polish for the Theme Store

- Reuse the shared CoverLightbox for the thumbnail preview instead of a
  second inline dialog — gains a visible close button and a focus-managed,
  portalled dialog, and drops duplicated markup.
- Theme cards expose aria-pressed so assistive tech announces the selected
  theme, not just the visual check.
- Transient store messages get live-region roles (loading/empty/install
  failure = status, fetch error = alert).
- Thumbnails degrade gracefully when offline/missing (hide the broken-image
  glyph; the thumbnail button no longer stretches with the row, so its
  background can't show as letterbox bars).

* feat(themes): larger store-row thumbnails (120x75 -> 200x125)

The list previews were too small to make a theme out; bump the display size
(same 1.6 aspect). Thumbnails are now served at 720x450, so the larger
display stays crisp.

* fix(themes): bust thumbnail cache on registry change

jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail
is updated the webview keeps showing its cached old image (the path is
unchanged). Append the registry's generatedAt as a cache-busting query to
the thumbnail URLs (list + lightbox); it changes on every themes push, so a
registry refresh makes the webview re-fetch and reflect the current CDN
image instead of a stale one.

* docs(themes): changelog + credits for the Theme Store

Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009)
and the matching line in the Psychotoxical credits.

* fix(themes): address PR review (uninstall hygiene, validation, polish)

Uninstall/scheduler & validation:
- uninstallTheme() repairs every selection slot (active + day + night), not
  just the manual one, and is shared by both uninstall buttons (dedup).
- Validate theme CSS at install time and skip persisting CSS that won't inject
  (no more "installed/active but renders nothing" with no feedback).
- Harden the runtime validator: exactly one rule, scoped exactly to the
  theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no
  at-rules, url() only data:, no expression()/javascript:, size-capped.

Tokens & polish:
- Fix three dangling undefined tokens (--surface-2 x2, --bg-surface).
- Finish the warning/success token sweep (--warning / --positive, themeable).
- Apply the active theme synchronously before React mounts (no first-frame
  flash) and inject installed themes up front.
- One-time, dismissible notice when the slim-bundle migration reset a theme.
- Update badge uses semver, not string inequality.
- Offline/stale indicator in the store; cross-window theme sync; drop the now
  dead REMOVED_THEME_REMAP and Card.mode field.

Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/
malformed), uninstallTheme (slot repair), migration notice. i18n in all nine
locales. Full suite green (1755 tests).

* test(bootstrap): cover startup theme apply + cross-window sync

The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to
bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3%
and failing the frontend hot-path coverage gate (>=70%). Add unit tests for
both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
2026-06-07 02:47:33 +02:00
cucadmuh 2d3c723a6e feat(offline): unify local playback, offline library, and favorites sync (#1008)
* feat(local-playback): LP-1 media layout and download_track_local

Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.

* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers

Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.

* feat(local-playback): LP-3 wire prefetch and playback to unified index

Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.

* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir

Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.

* feat(local-playback): migrate legacy offline files to media/library layout

Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.

* feat(local-playback): simplify offline disk migration and restore Offline Library UI

Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.

* feat(local-playback): disk-first offline reconcile and fast library tier discovery

Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.

* fix(offline): resolve local playback URLs across server index-key variants

Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.

* fix(offline): playlist cards, playback from Offline Library, and local URL routing

Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.

* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI

Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.

* feat(offline): show library disk usage in Offline Library header

Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.

* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap

Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.

* feat(offline): play-all cache card and stable Offline Library grid rows

Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.

* feat(offline): queue-cache grid card limited to media/cache

Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.

* chore(licenses): regenerate bundled OSS list for 1.48.0-dev

Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.

* feat(favorites): auto-sync starred tracks into separate media/favorites tier

Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.

* feat(favorites): compact offline toggle with disk icon and sync semaphore

Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.

* fix(favorites): trigger offline sync on star/unstar from anywhere

Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.

* fix(cache): skip hot-cache prefetch when favorites or library bytes exist

Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.

* fix(favorites): reconcile offline files on merged track union only

Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.

* feat(offline): add Favorites card to Offline Library

Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.

* feat(offline): show library+favorites disk total with icon breakdown

Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.

* feat(favorites): enable offline Favorites tab when auto-save is on

Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.

* feat(favorites): cross-server offline browse with per-server covers

When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.

* feat(playback): mixed-server queue scope and cross-server favorites sync

Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.

Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.

* fix(offline): suppress Subsonic calls during favorites and local playback

Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.

* fix(queue): export share and playlist save for active server only

Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.

* fix(offline): complete album pins and resume interrupted downloads

Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.

* fix(offline): pin queue, queued UI, and re-pin after remove

Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.

* fix(offline): remove Offline Library cards without full page reload

Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.

* fix(offline): artist discography pin state and queue handling

Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.

* fix(local-playback): address LP-1 review handoff (B1, M1–M7)

Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.

* fix(offline): cancel in-flight favorites downloads on unstar

Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.

* fix(build): resolve TypeScript errors blocking prod nix build

Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.

* fix(rust): satisfy clippy too_many_arguments for CI

Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.

* fix(settings): show correct hot-cache track count in Buffering section

Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.

* fix(media-layout): align truncation threshold on code points (M1)

Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.

* fix(test): use numeric cachedAt in hotCacheStore count test

Align test fixture with LocalPlaybackEntry type so tsc passes in CI.

* docs: add CHANGELOG and credits for offline experience PR #1008

* feat(offline): auto-sync manually cached playlists when track list changes

Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.

* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008

* fix(offline): exclude smart playlists from manual offline cache and sync

Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.

* feat(offline): auto-sync cached albums and artist discographies

Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.

* feat(offline): split pinned sync triggers by pin kind

Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.

* fix(offline): address PR #1008 review (N1, tests, pin queue)

Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
2026-06-06 22:43:03 +03:00
Frank Stellmacher dc4eef1a97 feat(fullscreen-player): rebuilt static fullscreen player (#1001)
* feat(player): static media-center fullscreen player (v1)

New lean fullscreen player: sharp full-bleed background (artist photo, cover
fallback), no blur / no continuous animations. Bottom-left big cover bottom-
flush with a full-width semi-transparent text bar (title with queue position,
artist, year/genre + rating stars, next-up); control row of transport · center
time · actions; full-width seekbar; live clock. Only the seekbar, time readout
and clock update at runtime, each owning its state (no per-tick re-render).
Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue
helpers. Wired in AppShell; old player kept for A/B.

* feat(fullscreen-player): true waveform seekbar instead of thin bar

Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller
canvas grows the bottom cluster upward, shifting the info/control rows up.
Height clamped to 32-52px.

* feat(fullscreen-player): up-next popover + control-bar styling

- Queue button opens a semi-transparent 'Up next' popover anchored bottom
  right; clicking a row jumps to that queue item.
- Larger bottom-right action buttons.
- Control row gets its own darker semi-transparent bar (touches the info bar
  above) for contrast; play button matches the plain transport buttons
  (no white circle, same size).

* feat(fullscreen-player): high-res (2000px) background cover

Fetch the full-screen background cover at the 2000px tier via the existing
on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP
outside the backfill pipeline) instead of the low-res 500px pipeline tier.
usePlaybackCoverArt now forwards a fullRes option to useCoverArt.

* feat(fullscreen-player): scrolling lyrics overlay + control-bar polish

- Lyrics button next to Queue toggles a centered, dark semi-transparent
  scrolling-lyrics overlay (reuses FsLyricsApple).
- Control bar darkened to match the lyrics overlay (0.88).
- Close button sized down to harmonize with the clock.

* feat(fullscreen-player): make rating stars clickable

The rating stars next to the year were display-only. Wire them to
queueSongRating (same path as the player bar / context menu): click to
set, click the current value to clear, with a hover preview. Keeps the
lucide outline look to match the rest of the control bar.

* fix(fullscreen-player): stable, high-res album cover

The foreground cover was keyed per track, so Navidrome's per-track
`mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded
the cover on every song change within the same album. Key it on albumId
(via useAlbumCoverRef) so it stays put while the album is unchanged.

It also used the low-res tier; reuse the fullRes 2000px cover already
fetched for the background so the foreground is crisp and both share a
single fetch/decode.

* refactor(fullscreen-player): remove the old player and its settings

The static rebuild is now the only fullscreen player. Delete the old
FullscreenPlayer component, its old-only parts (FsArt, FsPortrait,
FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test.
Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim) along with the Appearance
'Fullscreen player' section and its search entry. The new player always
shows the artist photo with cover fallback and has its own lyrics toggle.

Shared building blocks used by the static player (FsLyricsApple,
FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait)
are kept.

* i18n(fullscreen-player): translate the new player's strings

Wire the static player's previously English-only strings through i18n
across all 9 locales: now-playing label, track position, up-next label,
queue/lyrics/shuffle controls, and the queue overlay (title, empty,
close). Reuses existing queue.title / common.close keys; adds six new
keys to the player namespace.

* docs(changelog): note fullscreen player rebuild (#1001)
2026-06-05 14:23:47 +02:00
Frank Stellmacher 908c349cfd fix(mainstage): rename Home Page setting, start-page fallback + empty state (#975)
* fix(mainstage): rename Home Page setting, add start-page fallback + empty state

- Rename "Home Page" personalisation section to "Mainstage" across 9 locales
  so the heading matches the sidebar entry; add "mainstage" search keyword.
- Index route "/" now redirects to the first visible library item when the
  Mainstage sidebar entry is hidden, instead of stranding the app on a blank
  page. New pure resolveStartRoute() mirrors sidebar order + nav-mode gating.
- Show a guided empty state on Mainstage when every section is toggled off,
  with a CTA into Settings -> Personalisation.
- Unit tests for resolveStartRoute.

* docs(changelog): Mainstage rename, start-page fallback + empty state (#975)
2026-06-04 02:12:52 +02:00
cucadmuh 4ac373a65b feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter

Move Artists browse text search into the header Live Search with a page
scope badge (Users icon), field-local undo, and double-click/backspace
to clear scope. Block the live-search dropdown while scoped so results
only filter the Artists grid; mobile overlay follows the same rules.

* fix(artists): plain grid for scoped search fixes broken card layout

Route the browse grid through VirtualCardGrid, switch to non-virtual CSS
grid when live search filters the catalog, reset scroll on filter changes,
and skip content-visibility on plain tiles to avoid blank/black cards.

* fix(search): scope badge double-Backspace and single clear control

Require two Backspaces on an empty scoped field after prior text input;
one Backspace still clears the badge when the field was never filled.
Move live-search clear/advanced controls inside the field pill, drop the
extra outer clear button, and use type=text to avoid native search clears.

* fix(search): drop duplicate outer live-search clear button

Keep the native in-field clear on type=search and the original pill layout;
remove only the extra × control outside the search border. Reset dropdown
state when the query is cleared via the native control.

* refactor(search): generic scoped browse query helper, drop dead code

Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an
expectedScope argument; wire Artists via useScopedBrowseSearchQuery.
Remove unused liveSearchScoped dropdown helper (scoped mode blocks it).

* feat(search): ghost scope badge and single-click badge remove

After clearing the artists scope on /artists, show a faded ghost chip to
restore page-only search while keeping the global search placeholder.
Active badge removes on one click; tooltips and styles updated.

* feat(search): scoped live search for All Albums and New Releases

Wire albums and newReleases scope badges with debounced album title search
(local index title-only FTS + filtered search3). Plain grid, scroll reset,
and session query restore on album grid browse pages.

* fix(browse): preserve scroll restore after album/artist detail back

Only reset in-page scroll when filter resetKey changes, not when
isScrollRestorePending clears after session restore.

* feat(search): scoped live search for Tracks browse

Wire /tracks to header live search with wide title/artist/album FTS,
hide hero and discovery rails while search is active, and remove the
inline search field from the browse list.

* fix(search): clear header query when leaving scoped browse pages

Prevent global live search from firing on album/detail routes after a
scoped browse query; browse session stashes still restore on back.

* fix(tracks): restore scroll after back from detail during scoped search

Hold stashed song results across fetchSongPage churn, defer leave-stash
teardown past AppShell scroll reset, restore tracks scroll after the list
is ready, and save scroll snapshot when opening artist from song context menu.

* fix(tracks): hide discovery headings during scoped search

Hide the page subtitle and "Browse all tracks" section title when
tracks search is active, matching hero/rails chrome behavior.

* feat(search): scoped live search for Composers browse

Wire /composers to header live search with composers scope badge,
session stash, scroll restore, and plain grid/list during text filter.
Remove the in-page filter input; add i18n and navigation helpers.

* docs: CHANGELOG and credits for scoped browse live search (PR #938)
2026-06-01 13:04:36 +03:00
cucadmuh d3e5a6b704 feat: library browse navigation — restore filters, scroll, and search on back (#936)
* feat(albums): restore scroll position when returning from album detail

Save in-page scroll and grid depth when opening an album from All Albums,
then on browser back restore filters, preload enough rows, and apply scroll
before revealing the grid to avoid a visible jump from the top.

* feat(albums): smart back navigation and restore browse session on return

Remember the originating route when opening album detail, restore All Albums
filters/scroll on back (including explicit returnTo navigation), hide the grid
until scroll is applied, and fix filters being cleared after albumBrowseRestore
state is stripped from the location.

* feat(search): restore Advanced Search session when returning from album

Stash filters and results when leaving /search/advanced for album detail,
then restore them on back navigation (POP or returnTo with advancedSearchRestore).

* feat(search): restore Advanced Search album row scroll on return from album

Save horizontal scrollLeft when opening an album from Advanced Search and
reapply it via AlbumRow on return; keep main viewport at top. Add snapshot
helpers and session stash fields; extend AlbumRow with restoreScrollLeft.

* feat(search): restore Advanced Search session scroll and artist return path

Save filters, main scroll, and album-row scroll when leaving to album or
artist; restore without flash via hidden-until-ready. Add useNavigateToArtist,
restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change.

* feat(search): speed up Advanced Search back restore and year-only queries

Reveal the page right after sync scroll instead of blocking on full viewport
and album-row restore. Retry local index without the ready gate during sync;
use open-ended byYear params on network fallback, matching All Albums browse.

* feat(search): restore Advanced Search artist row scroll on back

Save leave snapshot when opening artist from ArtistCardLocal, persist
artistRowScrollLeft in session stash, and keep row restore targets in refs
so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop.

* feat(nav): route mouse back on album/artist detail like UI back

Trap history popstate when returnTo is set and call navigateAlbumDetailBack
so browser/mouse back restores browse/search session the same way as the header button.

* feat(artists): restore browse filters and scroll on back from artist detail

Persist Artists page filters, view settings, and vertical scroll when opening
an artist and returning via UI or mouse back, matching All Albums behavior.

* feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter

Serve /search and /search/advanced from one page with shared session restore
and scroll snapshot. Reset live search overlay state when navigating to full
search so the dropdown does not linger or reopen.

* feat(tracks): unify with search session and restore scroll on back

Route /tracks through AdvancedSearch with shared leave snapshot, song
browse stash, and main-viewport scroll restore when returning from album
or artist detail. Wait for hero/rails layout before applying scroll.

* refactor(search): rename AdvancedSearch page to SearchBrowsePage

The shared route shell serves /search, /search/advanced, and /tracks;
rename the page component and refresh stale file references in comments.

* feat(albums): restore New Releases and Random Albums on back from detail

Unify album grid leave-restore with surface-scoped session stash, live scroll
snapshot sync, and in-page scroll for Random Albums. Keep the same random
batch when returning from album detail; Refresh fetches anew and scrolls up.

* docs: add CHANGELOG and credits for PR #936
2026-06-01 03:11:27 +03:00
Frank Stellmacher 403979b35d feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782) (#884)
* feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782)

Some Linux users on WebKitGTK 2.50.x report a freeze when clicking
text fields: the field receives focus (right-click paste still works)
but the canvas never redraws until the window is resized. Likely a
layer-flush / composition scheduling bug in 2.50.x's Skia rendering
pipeline.

Off by default. When enabled in Settings -> System -> Behavior, every
input/textarea focus triggers a sync reflow read plus a one-frame
translateZ(0) toggle on the input's parent so WebKit re-evaluates the
layer tree. Side-effect: search-icon siblings flicker briefly on
focus -- accepted trade-off, only paid by users who opt in.

The toggle row is gated on IS_LINUX and sits next to the existing
Linux WebKitGTK options. The effect subscribes to the auth store and
re-attaches the focusin handler whenever the flag flips, so toggling
off cleanly removes the listener.

* i18n(settings): linux input repaint toggle in all locales

Adds linuxWebkitInputForceRepaint label + description across en, de,
fr, es, nl, nb, ro, zh, ru.

* docs(release): CHANGELOG and credits for linux input freeze workaround (PR #884)
2026-05-28 20:01:25 +02:00
cucadmuh 418b25914a feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)
* chore(cover): scaffold cover module and rust cover_cache stub

Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).

* feat(cover): add unified cover module and tier resolver (phase A)

Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.

* feat(cover): rust disk cache and tier-ready events (phase B)

Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.

* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)

Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.

* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)

Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.

* feat(cover): migrate sparse surfaces and integrations (phase E sparse)

Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.

* feat(cover): revalidation scheduler and disk pressure gate (phase E+)

Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.

* docs: CHANGELOG and credits for cover art pipeline PR #869

* fix(cover): stop webview getCoverArt storm on dense grids (429)

Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.

* fix(cover): omit empty img src until cover URL is ready

React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.

* fix(cover): disk cache by host index key, parallel ensure, asset protocol

Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.

* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage

Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.

* feat(cover): per-server cache strategy and native library backfill

Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.

* fix(build): CSP connect-src for Subsonic API; quieter prod nix build

Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.

* fix(cover): complete WebP ladder in library bulk backfill

Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.

* fix(cover,home): navigation-priority backfill and Because You Like UX

Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.

Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.

* feat(random-albums,library): local-first album fetch + cover art pipeline

Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.

- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
  и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
  прогревает следующий батч в фоне после каждого Refresh

Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).

* fix(random-albums): prevent double-load on Zustand rehydration

useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.

Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.

Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.

* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash

fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).

- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
  consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
  (аналогично non-reserve пути; при уже прогретом кэше — мгновенно)

* feat(because-you-like): reserve-first pattern — instant display on return visits

Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.

Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
  замыкания useEffect); читают текущий localStorage, возвращают
  { anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
  после отображения результата, кладёт следующий батч в _becauseReserve.
  Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
  нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
  (контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
  full-fetch path сохранён как fallback при первом визите или промахе

Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)

* fix(because-you-like): initialise state from reserve — no skeleton flash on remount

При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.

Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.

useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).

* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival

Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.

useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.

* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount

Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.

Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
   доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)

Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).

* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool

Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.

Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
  на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
  reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
  effect перезапустится когда pool заполнится (реальные deps изменятся)
  → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша

Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)

* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch

Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
  реальными mix-настройками → ~500ms → новый набор

Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.

Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.

* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()

Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.

В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.

* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits

Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).

Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.

Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.

* fix(mainstage): keep refresh without return flicker

Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.

* fix(mainstage): stabilize because rail and hero background framing

Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.

* fix(now-playing): prewarm track data and prevent stale carry-over

Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.

* fix(cover,test): refresh playback scope and default tauri cover mocks

Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.

* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks

Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.

* test(cover): hoist mocked useCoverArt and clean EOF whitespace

Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.

* fix(cover): align playback ensure auth and harden backfill retry flow

Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.

* fix(ci): resolve clippy lint and update frontend node runtime

Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.

* chore(settings): simplify cover and analytics strategy copy

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
2026-05-26 19:35:08 +03:00
cucadmuh 820f71c421 feat(dev): run dev alongside release with shared app data (#866)
* feat(dev): run dev alongside release with shared app data

Skip tauri-plugin-single-instance in debug builds so `tauri dev` can run
while an installed release instance is open. Keep the same bundle identifier
and data directory; label the dev window "Psysonic (Dev)".

* fix(dev): gate on_second_instance behind release cfg

Avoid dead_code warning in debug builds where single-instance is skipped.

* feat(dev): red sidebar brand and monochrome titlebar chrome

Tag the document in Vite dev and style the logo header with a red
background plus gray window controls so dev is obvious at a glance.

* feat(dev): skip OS hotkeys and add mobile DEV markers

Debug builds no longer register global shortcuts, MPRIS, or Windows
taskbar media controls so release keeps system input when both run.
Flip the tray icon horizontally in dev and show a fixed DEV badge on
narrow layouts.

* docs(changelog): note PR #866 parallel dev alongside release

* fix(dev): satisfy clippy needless_return in debug-only paths
2026-05-24 23:39:57 +03:00
cucadmuh 11974e1438 feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864)
* feat(analysis): align index settings and per-server strategies

Rebuild the local index UX to live under Servers with per-server analytics
strategies, and scope analysis queue hints/pruning by playback server so
priorities stay isolated across profiles.

* feat(analysis): add progress tracking and server analysis deletion functionality

Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features.

* feat(server): implement server index key migration and enhance server ID resolution

Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations.

* refactor(library): simplify server ID handling in sync progress and idle subscriptions

Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality.

* refactor(analytics): rename advanced strategy to aggressive and update descriptions

Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code.

* fix(audio): update server ID handling in audio progress functions

Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure.

* refactor(analysis): update server ID handling and drop legacy keys

Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability.

* refactor(migration): switch to strategy C dual-db flow

Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior.

* fix(migration): harden runtime db switch and startup gate

Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows.

* feat(migration): enhance migration reporting with skipped server rows tracking

Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status.

* fix(migration): avoid startup blocking modal on no-op runs

Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed.

* fix(migration): enforce startup precheck and purge unknown rows

Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB.

* fix(migration): block UI during done-flag precheck

Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed.

* fix(migration): hide precheck modal when no migration is needed

Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users.

* fix(migration): cleanup legacy db files after path migration

Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration.

* docs(changelog): add PR #864 release notes and contributor credit

Document the full index-key rebuild scope for 1.47.0 and add the
corresponding settings credit entry for PR #864.

* test(analysis): raise hot-path coverage for analysis cache

Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths.

* fix(migration): make rebind pass resilient to foreign key ordering

Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates.

* feat(backup): add dual-database backup flow and blocking UX

Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs.

* docs(changelog): add PR #864 backup notes and contributor credit

Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh.

* docs(changelog): sort 1.47.0 entries from old to new

Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block.

* fix(playback): align offline/hot cache lookup with indexKey scope

Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
2026-05-24 21:11:04 +03:00
Frank Stellmacher 090a31bc82 refactor(queue): track resolver + selectors (thin-state phase 2) (#859)
* refactor(queue): add queue track resolver (thin-state phase 2a)

Standalone resolver: QueueItemRef → Track via index batch
(library_get_tracks_batch, ≤100/call) → network getSong fallback (P8), into a
bounded LRU cache. Holds raw tracks; session star/rating overrides (F4) merged
on read via applyQueueOverrides. Sync getCachedTrack + subscribeQueueResolver
for selectors; resolveVisibleRange prefetches a [-50, +200] window; carries
queue-only flags from refs; invalidate drops entries after a sync succeeds.

Not wired into the store/UI yet — phase 2b does that.

* refactor(queue): extract toQueueItemRefs helper

Pure helper deriving thin QueueItemRefs from a Track[] queue (per-item
serverId, queue-only flags), shared by the persist partialize and the
upcoming phase-2b resolver bridge. No behaviour change.

* refactor(queue): resolver bridge + queue selectors (thin-state phase 2b)

Seed the resolver cache from the canonical queue (queueResolverBridge, a
windowed [-50, +200] seed around the current index) and add the stable queue
selectors (useQueueTrackAt / useCurrentTrack / useQueueItems).

Additive: the store stays queue: Track[]-canonical; consumers migrate onto the
selectors in phase 3, and the selector impls move to the resolver once
queue: Track[] is dropped in phase 4. No mutation or persist change — the
persisted queueItems keeps its single restore role (no dual-role clash).

* docs(changelog): on-demand queue track loading groundwork (#859)
2026-05-22 23:30:49 +02:00
cucadmuh e8e41752a7 feat(playback): global speed with three strategies (#852)
* feat(playback): global speed with three strategies

Add Settings → Audio and player-bar controls for global playback speed
(speed with auto pitch correction as default, varispeed, manual pitch shift).
Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough.

* fix(playback): align seekbar, seek, and progress on content timeline

Unify UI timebase across varispeed and preserve strategies: full-track
duration, speed-scaled progress for DSP paths, and content-timeline seeks
without varispeed scaling. Reset the sample counter after seek so clicks
land correctly; restart playback on strategy/enable changes instead of
fragile hot-switching.

* fix(ui): anchor playback speed popover like volume controls

Replace the centered EQ-style modal with a player-bar popover (outside
click, Escape, reposition on scroll). Show compact controls in the bar
and overflow menu; keep strategy hints and labels in Settings only.

* docs(release): CHANGELOG and credits for playback speed (PR #852)

* docs(changelog): add playback speed entry for PR #852

* fix(clippy): simplify raw_counter_samples branch for CI

Collapse duplicate if branches flagged by clippy::if-same-then-else.

* fix(ui): wheel on pitch slider adjusts pitch in speed popover

In compact player-bar controls, scroll over the pitch row changes pitch;
elsewhere in the panel changes speed. Stop propagation so overflow menu
wheel does not tweak volume.

* fix(playback): address PR #852 review and drop ineffective dynamic imports

Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample
counter on live preserve-path speed changes; use neutral rate atomics for
radio progress; static-import playerStore in playListenSession (move preview
volume sync to previewPlayerVolumeSync side-effect module).

* fix(i18n): translate playback-rate strategy labels in all locales

Replace leftover English Varispeed/Pitch strings in ru and other non-en
settings blocks so popover strategy buttons and hints read natively.

* fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo"

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-22 18:59:28 +02:00