genreBrowsePlayback is a "play X" queue builder -- it turns a genre seed into a
Track[] for the player, the genre analogue of playArtistShuffled which already
lives in features/playback/utils/playback. Consumed only by the 3 genre pages,
by no utils/library sibling, so the whole-file move is clean (no split). Its
Track/songToTrack edges become intra-feature; its remaining utils/library
siblings (advancedSearchLocal, albumBrowseSort, genreCatalogCountsCache,
genreAlbumBrowse, libraryReady) are plain infra deps.
utils/library now has ZERO @/features importers (source AND tests) -- the bulk
is ready to relocate to lib/library. tsc 0, lint 0, genre suite green.
shuffleArray is a pure generic Fisher-Yates over T[], misfiled in
features/playback. Move to lib/util (next to dedupeById): fixes the existing
core->feature edges (utils/componentHelpers/*) and drops one of
genreBrowsePlayback's playback deps.
albumBrowseCatalogChunk is the feature-layer orchestrator that picks the offline
branch on top of the pure lib catalog loaders; its sole consumer lives in
features/album and its offline dep is a legal feature->feature edge there. Its
.test.ts stays in utils/library -- that test exercises fetchLocalAlbumCatalogChunk
from albumBrowseLoad (misnamed), which is not moving.
utils/library now has a single remaining feature importer: genreBrowsePlayback
(songToTrack + type Track), blocked on the Track domain model living in
features/playback. tsc 0, lint 0, targeted suites green.
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.
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.
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.
Add core seam store/orbitRuntime.ts: a registry (registerOrbitRuntime) exposing a
neutral session snapshot {role,phase,state} + an async bulkGuard, plus pure
derivations mirrored from the feature (isInOrbitSession, isOrbitPlaybackSyncActive,
estimateLivePosition). Default (unregistered) = neutral snapshot + bulkGuard allow
— bit-identical to today's no-session behavior, and a session can only start via
the topbar which loads the @/features/orbit barrel (→ registers) first.
features/orbit/utils/orbitBulkGuard registers the runtime at module init
(store-backed getSnapshot + the existing confirm-modal orbitBulkGuard as the gate).
The orbit feature keeps its own copies of the pure helpers for UI (incl. the
arg-form isOrbitPlaybackSyncActive(role,phase) used by two settings/player-bar
components), so nothing UI-facing changes.
Repointed the 9 audio-core sites (playbackRateStore, previewStore,
playbackReportSession, nextAction, resumeAction, playTrackAction,
queueMutationActions, playAlbum) from @/features/orbit to @/store/orbitRuntime;
state reads (useOrbitStore.getState().role/.state) become orbitSnapshot().
Migrated 6 audio-core test mocks to @/store/orbitRuntime via importOriginal-spread
(keeps registerOrbitRuntime callable). enqueueShareSearchPayload stays on the orbit
barrel (share util, not audio core) — its test mock unchanged.
Decouple Step 3 — last seam. The audio ENGINE is now free of @/features/* runtime
imports (only type-only edges + the non-engine composerBrowseSessionStore browse
store remain). Unblocks the playback-core move + utils/library→lib.
tsc 0, lint 0, full suite 319/2353 green.
NEEDS Frank's live-session QA before relying on it (host+guest bulk-replace modal,
guest catch-up, rate/preview/scrobble suppression).
Add core seam store/mediaResolver.ts: a registry (registerMediaResolver) with
delegating resolveAlbum/resolveArtist/resolvePlaylist + the pure auth wrappers
resolveMediaServerId/resolveAlbumForActiveServer/resolveAlbumForServer and the
ResolvedAlbum type. Default (unregistered) = network-only safety net (no
library-index/offline branch — those stay in the feature).
features/offline/utils/offlineMediaResolve keeps the offline-aware policy impls
and registers them at module init (registerMediaResolver). The call runs at boot:
AppShell eagerly imports the @/features/offline barrel, whose export * evaluates
offlineMediaResolve. The 3 thin wrappers + ResolvedAlbum move to the core seam and
are re-exported from offlineMediaResolve so the offline barrel still surfaces them
for the ~25 UI consumers (unchanged).
Repointed the 5 audio-core call sites (fetchTracksForSource, playAlbum,
playArtistShuffled, playByOpaqueId, luckyMixHelpers) from @/features/offline to
@/store/mediaResolver. Migrated playAlbum.test's mock to the new module via
importOriginal-spread (keeps registerMediaResolver callable when the offline
barrel loads transitively — partial-mock-collapse otherwise).
Decouple Step 2b. The audio core's only remaining @/features/offline reference is
now the type-only localPlaybackMigration import (erased). Runtime offline edge gone;
orbit seam (Step 3) remains.
tsc 0, lint 0, full suite 319/2353 green.
Pull findLocalPlaybackUrl/findLocalPlaybackEntry/hasLocalPersistentPlaybackBytes
+ the entry/index-key membership helpers (entryBelongsToServer,
indexKeyBelongsToServer, findFavoriteAutoEntry, hasLocal{Library,FavoriteAuto}Bytes)
out of features/offline/utils/offlineLibraryHelpers into a new core module
store/localPlaybackResolve.ts. The substrate depends only on authStore +
localPlaybackStore + serverIndexKey utils (no useOfflineStore), so the audio
core can resolve on-disk bytes without inverting into @/features/offline.
Repointed the audio-core consumers (crossfadePreload, playbackUrlRouting,
resolvePlaybackUrl, playTrackAction, promoteStreamCache, hotCachePrefetch,
hotCacheStore) + offline-internal callers + favorites hook; moved the two
barrel vi.mocks onto the new leaf module. offlineLibraryHelpers re-imports the
two primitives it still needs internally.
Decouple Step 2a. Removes the local-bytes offline edge from the audio core;
the resolve* media-resolution family (Step 2b) + orbit seam (Step 3) remain.
tsc 0, lint 0, full suite 319/2353 green.
First step of decoupling the audio core from feature imports. coerceOpenArtist
Refs is a pure Subsonic-response normalizer (one-object-vs-array quirk) that was
mis-membered in features/artist but consumed by the audio core (songToTrack,
trackArtistRefs) and others. Relocate it beside its SubsonicOpenArtistRef type in
lib/api/; drop the artist-barrel re-export; repoint the 4 barrel consumers.
Removes the @/features/artist runtime edge from the audio core (utils/playback)
entirely. tsc 0, lint 0/0, suite 319/2353 green.
(composerBrowseSessionStore's ALL_SENTINEL import from @/features/artist remains
— that's a non-audio global browse store, separate from this decouple.)
The shortcut contract (shortcutActions.ts + shortcutTypes.ts) is NOT a
pure-frontend file: src-tauri/src/cli/parse.rs include_str!s
'/../src/config/shortcutActions.ts' at compile time, so moving it to
lib/shortcuts/ (slice 7ad19671) broke the Rust build (couldn't read the file ->
dev build won't start). Revert the contract back to src/config/ — it has a
build-time backend path contract and belongs where Rust anchors it, not in lib/.
No Rust file touched (fix = restore the frontend path the backend expects).
cargo check green (13s), tsc 0, lint 0/0, frontend suite 319/2353 green.
Lesson: grep src-tauri for include_str!/include_bytes! '../src/...' before
moving ANY frontend file — Rust can pin a TS file by path.
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.
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.
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.
Move the 5 subsonic api modules that M3 had co-located into features
(subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the
feature-free infra layer, and drop their feature-barrel re-exports. Consumers
now import these protocol calls directly from @/lib/api/subsonic*; the feature
barrels export only domain UI/hooks/state.
This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol
clients are feature-free infra, not per-feature) and it kills the documented
api-induced feature<->feature cycles: offline->artist/playlist (getArtist/
getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*.
Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist
Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper,
tracked for M5, not api placement.
Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour
change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
Tooling note: barrel-imported and dynamic-import api symbols were split out of
@/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the
deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
Move the Subsonic + Navidrome protocol clients and the library IPC facade
(subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/,
the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/).
Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150
consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis
infra stay in src/api/ pending their own M4 placement.
tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
The album barrel reaches genreBrowsePlayback.test's transitive graph through the
artist↔album edge (songToTrack → @/features/artist → @/features/album →
useGenreAlbumBrowse), which needs GENRE_ALBUM_FIRST_PAGE from the partially
mocked ./genreAlbumBrowse. Spread importOriginal so it stays real.
The move tool rewrote single-module mocks (e.g. vi.mock('@/utils/orbitBulkGuard'))
into feature-BARREL mocks (vi.mock('@/features/orbit', ...)). A partial barrel
factory shadows every other barrel export to undefined — and src/test/helpers/
storeReset.ts reads useOrbitStore.getState() at module load, so 6 test files
(playerStore.misc/events/queue/playbackActions, ContextMenu, QueuePanel) crashed
at collection (0 tests run, mis-read as flaky teardown). Retarget each collapsed
mock to its deep submodule so the barrel re-exports the stub while siblings
(useOrbitStore, offlineActionPolicy, usePlaylistStore) stay real.
The offline move collapsed a single-module mock into a `@/features/offline`
barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied
on. Mock the reload-token submodule directly so the barrel re-exports the stub
while the sibling context hook stays live.
The M0 pilot mis-membered the waveform data pipeline into features/waveform,
creating a core->feature inversion: ~12 audio-core files in store/ imported
refreshWaveformForTrack / fetchWaveformBins / bumpWaveformRefreshGen from the
feature barrel. These are playback coordination (write waveformBins into
playerStore on track change, last-write-wins gen guard), not seekbar rendering.
Move waveformRefresh + waveformRefreshGen (+ tests) to src/store/ and
waveformParse (+ test) to src/utils/waveform/ (beside waveformSilence). The
feature now holds only the seekbar UI (WaveformSeek/SeekbarPreview + their
hooks/render utils); its barrel exports UI only. Core consumers import the
pipeline directly from @/store/*. Restores the no-core->feature-inversion rule.
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.
* feat(settings): add "Square Corners" appearance toggle
A Display toggle in Settings → Appearance → Visual Options that overrides
the active theme to render cards and cover art with square (non-rounded)
corners, for a sharper, boxy look.
Covered surfaces: grid cards (album/playlist/artist/song/because) and
their covers, detail-header heros (album/playlist/tracks), the Now
Playing / Radio view, the fullscreen player, the cover lightbox, the
queue panel cover, and the mini player.
Persisted in themeStore and driven by an `html[data-square-corners]`
attribute set in App.tsx — applied in both the main and mini-player
webviews (the mini player rehydrates the theme store on the shared
`storage` event). Strings added for all 12 locales.
* docs(changelog): add Square Corners to 1.50.0 CHANGELOG and What's New
Set the 1.49.0 release date, fix Added PR order for Polish (#1185),
refresh WHATS_NEW highlights for missing user-facing fixes, and credit
ImAsra for WinGet release automation (#1077).
Extract the drag-to-reorder boilerplate the sidebar, artist-layout, lyrics,
queue-toolbar and servers customizers each hand-rolled into a single
`useListReorderDnd` hook plus an id-based `applyListReorderById`, and migrate
all five onto it. Reorders resolve by stable item id, never positional index,
so a render filter can never share an index space with the reorder and desync
it (the class behind #1164). No user-facing change.
- New: useListReorderDnd, applyListReorderById (+ tests), ReorderGripHandle.
- applySidebarReorderById now builds on the shared core; section/conserved
guards stay sidebar-specific.
- Queue/mini-queue/playlist reorder stay backend-index based (out of scope).
* fix(sidebar): reorder nav items by id instead of positional index
The Settings sidebar customizer rendered library rows with one filter and
reordered them with another, so a hidden/gated item (luckyMix when AudioMuse
is unavailable) made the rendered list shorter than the reorder list and drag
indices landed one slot off — rows jumped back on drop. Replace the index-based
reorder with an id-based primitive shared by the customizer and the in-sidebar
long-press DnD, so a render filter can no longer desync the move. Unknown,
cross-section, conserved, or no-op drops are rejected.
* docs(changelog): note sidebar reorder fix (#1206)
* fix(cover): build cover tiers from the full-resolution source
Derive the larger tiers from the decoded download instead of re-reading the just-written smaller tier (resize never upscales, so 512/800 were stored at the small resolution). Resolve the full-res 2000 tier exactly so it is actually downloaded and cached. Bump the cache layout stamp to drop already-poisoned tiers.
* fix(cover): open the cover lightbox at full resolution
Require the exact requested tier for the full-res helper so a smaller warmed tier no longer pins the lightbox to a downscaled image. Race the 2000 fetch against a 500ms opening window, show the 800 tier meanwhile, and persist 2000 for the next open. Adds an opening animation (reduced-motion aware) and tests.
* docs(changelog): note full-resolution cover fix (#1205)
* fix(cover): keep full-res peek exact in peek_batch and the grid seeder
Review follow-up: ensure-path peek alone left a hole — cover_cache_peek_batch still laddered a 2000 request down to a smaller tier, and the in-memory grid seeder wrote that smaller path under the 2000 key, so Hero/fullscreen/lightbox surfaces (which peek 2000 before ensure) still showed a downscaled cover. Share one exact-2000 rule (peek_plain_cover_tier) across ensure and peek_batch, and never seed the full-res key from a smaller tier file.