On Windows/MSVC the test executables for `psysonic-analysis`, `psysonic-audio`
and the top `psysonic` crate link the wry/tao windowing runtime (via the tauri
dependency) and statically import `TaskDialogIndirect` from comctl32. That symbol
only exists in Common-Controls v6; System32 comctl32.dll is v5.82 and lacks it,
so an unmanifested test exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND
(0xC0000139) before any test runs. The app binary avoids this through the
manifest tauri_build embeds.
Declare the Common-Controls v6 dependency on the test binaries via build-script
link args, gated to windows-msvc. The app binary's manifest is unchanged
(byte-identical: tauri_build already embeds the same dependency, the linker
dedupes). Non-Windows/CI builds are unaffected.
* feat(fullscreenPlayer): add Prism style — full-bleed backdrop, right lyrics panel, glass control bar
Third fullscreen player style (Settings → Appearance). Reuses the existing
pipeline: full-bleed artist backdrop (fanart/artistBackdrop), FsLyricsApple in a
floating right-side glass panel, player-store transport, cover-derived accent.
New: the bottom bar layout (transport · time elapsed/−remaining · now-playing
pill with integrated scrubbable progress · volume/queue/lyrics-toggle/minimize)
and fullscreen-player-prism.css. Wired into the style toggle + AppShell routing
+ picker + 13 locales.
* fix(fullscreenPlayer): Prism controls stay visible; transport/utils float bare (no 3-box split)
* fix(fullscreenPlayer): Prism bottom bar is one continuous glass bar (not three separate elements)
* fix(fullscreenPlayer): Prism control row is a centred middle band; only the now-playing element is boxed
* fix(fullscreenPlayer): Prism bar has both an outer glass strip and a darker nested now-playing box
* fix(fullscreenPlayer): Prism outer bar is half-width and centred
* feat(fullscreenPlayer): Prism lyrics — progressive blur on upcoming lines + accent-tinted active line
* style(fullscreenPlayer): centre the title/album/artist text in the Prism now-playing pill
* refactor(fullscreenPlayer): share seek/volume/backdrop/time across FS players
The Prism player had copy-pasted the seekbar, volume, time readout and
backdrop resolution from the Static/Immersive players. Two of those copies
carried bugs: the progress input dropped FsSeekbar's touch/pointer handlers
(no scrubbing on touchscreens) and the volume toggle only recorded the
pre-mute level on the mute click (unmuting after dragging the slider to 0
restored a stale value).
Extract the shared logic into single sources of truth and rewire all three
players onto them:
- useFsArtistBackdrop — the artist-backdrop URL resolution (was duplicated
verbatim in Static, Immersive and Prism).
- useImperativeSeek — the drag/preview/commit + progress-subscription loop
with mouse, touch, pointer and keyboard handlers; FsSeekbar and Prism's
progress line now share it, so touch scrubbing works everywhere.
- useVolumeToggle — mute toggle that continuously tracks the last non-zero
volume, restoring it correctly regardless of how the level reached 0.
- FsTimeReadout gains `remaining`/`className` props and replaces Prism's
bespoke time component.
Drops a dead aria-valuetext no-op on the progress input. Adds regression
tests for the volume-toggle restore path and a Prism smoke test.
* docs(changelog): add Prism fullscreen player style (#1251)
* feat(fullscreenPlayer): recover immersive player — components, hooks, CSS, settings
Phase 1 of the Minimal/Immersive style toggle. Recover the pre-#1001 fullscreen
player from history into the feature folder and make it compile against the
current architecture:
- FullscreenPlayerImmersive + Fs{Art,Portrait,Seekbar,LyricsMenu,LyricsRail}
- hooks useFsDynamicAccent, useFsArtistPortrait
- fullscreen-player-immersive.css (core/mesh/portrait/controls/seekbar + rail +
lyrics-menu + no-compositing overrides), shared Apple-lyrics stays in
adaptive-portrait.css
- re-add settings removed in #1001: showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim; plus the new fullscreenPlayerStyle toggle
Not wired into the shell yet (Phase 2). The two hooks still trip the current
set-state-in-effect lint rule — both are reworked in Phase 3 (portrait rewire to
artistBackdrop + dynamic-accent perf gating), which resolves it.
* feat(fullscreenPlayer): style toggle — route Minimal/Immersive + Appearance picker (13 locales)
* feat(fullscreenPlayer): immersive artist portrait via fanart backdrop pipeline; fix hook lint
* refactor(playback): add feature barrel; route immersive imports through barrels (dep:check)
* fix(fullscreenPlayer): restore lyrics-style i18n (13 locales) + close popover on style pick
* feat(fullscreenPlayer): immersive Apple-lyrics mode shows artist image as dimmed full-screen backdrop
* fix(fullscreenPlayer): address code-review findings on the immersive player
- star: pass currentTrack.serverId to queueSongStar (multi-server correctness)
- cover: use album-keyed ref (useAlbumCoverRef) to stop per-track cover flicker
- backdrop: honour backdrops.fullscreenPlayer.enabled (toggle was ignored)
- lyrics popover: Escape now closes only the popover, not the whole player
(capture-phase listener + stopPropagation vs useFsIdleFade's bubble handler)
- settings: recover the Show-artist-photo toggle + photo-dim slider (13 locales),
shown for the immersive style — the persisted settings had no UI
- apple mode: don't mount the (CSS-hidden) portrait — the full-screen backdrop
already shows the image; avoids a duplicate 2000px load/decode per track
- dynamic accent: cache-as-source-of-truth + last-shown ref so a cache-hit album
no longer surfaces a stale accent from an earlier album
- rehydrate: clamp fsPortraitDim (0–80) so a malformed value can't yield NaN dim
- FsArt: clear the layer when a track has no cover (was leaving prior art)
- FsSeekbar: pause the progress subscription during keyboard seeking (onKeyDown)
* fix(fullscreenPlayer): stop the immersive scrim over-darkening the artist portrait in rail mode
* fix(fullscreenPlayer): dynamic cover accent — re-run extraction when the async cover src resolves
* refactor(fullscreenPlayer): dynamic accent reads the cover blob via the cover cache, not a raw fetch
* test(fullscreenPlayer): unit-test dynamic accent hook + immersive player render/control smoke
* docs(changelog): note fullscreen player styles + credits (#1249)
* fix(fullscreenPlayer): satisfy npm run lint — drop no-explicit-any casts and ref-in-render
* fix(themes): theme card what's-new shows only the latest version
* feat(themes): credit community theme authors in Settings System tab
* docs(changelog): note theme contributor credits and card what's-new (#1248)
* fix(discord): drop server cover source that leaked credentials
The "server" Discord cover source built an authenticated Subsonic
getCoverArt URL (carrying the username, auth token, and salt) and handed
it to Discord as the large image. Discord fetches external images through
its own proxy and exposes the full source URL to anyone viewing the rich
presence, leaking replayable server credentials.
- Remove the "server" cover source; keep "None" (app icon) and
"Apple Music" (iTunes, credential-free), both resolved without server auth
- Migrate persisted "server" preference to "None" on rehydrate; new default
is "None"
- Drop the now-dead frontend cover-URL builder and its test
- Move the two-option picker to the shared SettingsSegmented control
- Remove the obsolete locale key across all 13 locales
* docs(changelog): note Discord server cover credential fix (#1246)
* feat(themes): show per-theme changelog and pin updatable themes
Surface theme updates in the store: each card gains an expandable "What's
new" built from the theme's optional manifest changelog (versions listed
newest first), and installed themes with a pending update now float to the
top of the list in both sort modes so they are easy to find.
* i18n(settings): add themeStoreWhatsNew across locales
New "What's new" label for the theme-store changelog disclosure, in all 13
locales.
* docs(changelog): add theme store changelog and pinned-updates entry (#1240)
* fix(playlist): batch playlist writes past the GET URL limit (#1227)
Adding tracks failed past ~341 songs because the write path re-sent the
entire song list as createPlaylist.view?songId=<all> query params on a GET,
blowing past the server's ~8 KiB URL limit. Writes now append incrementally
via updatePlaylist.view?songIdToAdd=<batch> (and songIndexToRemove for
clears/removals) in 150-id batches, so there is no practical size cap. A
per-server in-memory membership cache removes the full getPlaylist refetch
on every dedup, fixing the "slow add on big playlists" report.
Layering detangle (keeps the new cache from adding dep-cruiser cycles):
- lib/api/subsonicPlaylists.ts is pure of the store again; cache invalidation
on write failure moved to the feature callers' catch blocks.
- membership cache extracted to the core layer (src/store/playlistMembershipStore.ts)
so offline/orbit/contextMenu/playlist read it directly instead of routing
through the @/features/playlist barrel.
- severed the offline -> playlist-barrel edge (pinnedOfflineSync name fallback
through the live playlist list was dead: nameless callers are all gated by
isSourcePinnedOffline, where offline meta already carries the name).
- confirmAddAllDuplicates moved into the playlist feature; contextMenu submenus
import the add/merge helpers via the playlist barrel, not deep paths.
dep-cruiser baseline regenerated: 742 -> 714 (net -28, all no-circular). The
churn in the baseline is path-shift of the frozen playerStore SCC (cover/
playback/orbit), not new coupling; the new playlist modules have zero violations.
* docs(changelog): record #1235 playlist URL-limit fix (changelog + credits)
* fix(playlist): seed membership cache from full list, not library-scoped view (#1235 review)
F1: runPlaylistLoad seeded the dedup membership cache from the
library-scope-filtered songs, so out-of-scope members looked "new" and
addTracksToPlaylistWithDedup/collectMergeSongIds could re-add them as
duplicates. Cache now holds the full unfiltered server list while the UI
still shows the filtered view; add a regression test.
Also documents the two accepted trade-offs flagged in review:
- F2: >batch updatePlaylist clears then appends non-atomically (URL-limit
workaround); a mid-step failure truncates server state, cache invalidation
lets the client re-read truth.
- F3: dedup read-modify-append isn't atomic across the await; rare missed
dedup on concurrent adds, self-heals on next load.
Retroactive changelog + credits for the already-merged feature-folder restructure (PR #1225). Adds a 1.50.0 Changed entry (internal restructure, no user-facing change; additional architecture credited to cucadmuh) and a contributions line under Psychotoxical. cucadmuh is intentionally not added to the settings credits list.
FE cutover for the D1 slice: libraryGetCatalogYearBounds and
libraryGetGenreAlbumCounts now call the generated commands.* bindings instead of
a hand-written invoke<T>('cmd', ...), unwrapping the specta Result union (rethrow
on error — behavior-preserving). The duplicated hand DTOs collapse into named
aliases of the generated CatalogYearBoundsDto / GenreAlbumCountDto, so the shape
lives in one place (the contract) and consumers stay unchanged.
Verified the guard end to end: a serde-rename of the Rust field (contract change,
Rust still compiles) regenerated bindings and made tsc fail at the FE consumer;
reverted. cargo + tsc now enforce this slice's contract at compile time.
The serverCapabilities cluster (catalog, context, resolve, storeView, types) is
lib-level infra: it imports only @/lib/server and is consumed by both lib and
feature code, so a feature home would invert the two lib consumers. Move it to
src/lib/serverCapabilities and repoint consumers.
Pure move + import updates, no behavior change. Baseline gains one lib->store
entry (storeView reads authStore, only now caught under lib/).
The five residual src/hooks/ hooks (useConnectionStatus, useCardGridMetrics,
useTracklistColumns, useNavidromeAdminRole, useAnalysisPerfListener) are all
domain-agnostic — none imports @/features — so they join the other shared hooks
in src/lib/hooks/ (with their two colocated tests). Consumers repointed;
relative-up imports in the moved files become the @/ alias. src/hooks/ is removed.
Pure move + import updates, no behavior change. The layering baseline gains five
lib->store entries (the three hooks that read authStore/devOfflineBrowseStore are
only now caught under lib/) — same documented debt class as the api move.
The legacy src/components/ dir held eight shared modals. Audited each: the five
generic primitives (Modal, ConfirmModal, GlobalConfirmModal, ExportPickerModal,
ThemeMigrationNotice) move to src/ui (the shared-primitive home). The three that
import @/features/* are feature-owned, so they move into their feature instead of
polluting ui with core->feature edges: SongInfoModal -> features/playback,
LicenseTextModal -> features/settings, PasteClipboardHandler -> features/share.
Pure move + import updates, no behavior change. The layering baseline is unchanged
(every relocated import lands in a legal direction: feature->ui/store/lib, or
cross-feature only via the barrel). src/components/ is now removed.
The seven remaining src/api/ modules (analysis, azuracast, bandsintown,
coverCache, migration, network, runtimeLogs) were the last IPC-layer files
outside src/lib/api/, where the rest of the FE IPC clients already live. Move
them (+ the colocated coverCache test) into lib/api and repoint every consumer;
relative-up imports in the moved files become the depth-independent @/ alias.
Pure move + import updates — no behavior change. The layering baseline is
regenerated in the same commit: the moved files' pre-existing store/cover edges
are only now caught under lib/, and the cover<->coverCache cycles are the same
edges re-keyed to the new path (E4 shrinks the allowlist later).
First vertical slice of the tauri-specta rollout: annotate two psysonic-library
browse-metadata commands (library_get_catalog_year_bounds,
library_get_genre_album_counts) with #[specta::specta] and derive specta::Type on
their DTOs (CatalogYearBoundsDto, GenreAlbumCountDto), then add them to
collect_commands!. bindings.ts now carries their typed signatures + DTO shapes
(serde camelCase carried through). Non-breaking: generate_handler! stays the live
handler and nothing imports the bindings yet.
Both DTOs are i64-free (i32/u32/String), so no BigInt handling is needed here —
that convention is decided when the first i64 DTO is annotated. Generated
src/generated is excluded from eslint (its runtime helper uses `any`); tsc still
type-checks bindings.ts.
Stand up the FE<->BE contract pipeline end to end with near-zero surface: a
tauri_specta::Builder collects one proven command (greet) and exports typed TS
bindings to src/generated/bindings.ts. The existing generate_handler! stays the
live invoke handler — no FE change yet.
Safety: the export runs only under #[cfg(debug_assertions)] (debug launch) and a
headless test, never in a release build, so a specta RC break can never block a
release cargo build; the committed bindings.ts is plain TypeScript for tsc. The
snapshot is committed (gitignore exception) so CI diffs catch contract drift.
Pinned deps: specta / tauri-specta =2.0.0-rc.25, specta-typescript =0.0.12 (the
latest mutually-consistent set; rc.21 + specta-typescript 0.0.9 no longer resolve).
Grow collect_commands! crate-by-crate next, starting with psysonic-library.
After a server URL edit, rewriteFrontendStoreKeysForRemap must repoint the old
index key to the new one across offline album keys, local-playback entries, and
the player queue (queueServerId + per-item refs), leaving unrelated keys
untouched. A same-key remap is a no-op.
Cover switchActiveServer end to end with the heavy deps mocked and the real orbit
+ auth stores driving assertions: unreachable aborts; host tears down via
endOrbitSession, guest via leaveOrbitSession, both reset the role; the old
server's queue is flushed, the active server rebinds, and a queue handoff is
marked. Closes the switchActiveServer QA.
Route a multi-track enqueue through the real enqueue action and the orbitRuntime
bulkGuard seam: over-threshold + accept commits the tracks, reject commits none,
and a single track bypasses the guard entirely.
Exercise the real hasLocalPlaybackUrl + subsonicNetworkGuard: a track with local
psysonic-local:// bytes (any tier) skips the reachability probe even when the
server is unreachable, while a non-local track stays gated by reachability.
Closes the local-bytes network-skip QA (review residual #1).
Register the real offlineMediaResolve into the mediaResolver seam and drive its
offline-vs-network routing through the actual decision inputs: offline-browse
active + local browse enabled reaches the local-bytes path, otherwise the
network path. Asserts which data source the seam call reaches. Closes the
offline-aware media resolution QA (review residual #2).
Guard shape drift when an old persisted blob rehydrates. Cover the
analysisStrategyStore v0 to v1 migrate (preserves strategy, clamps parallelism,
adds the per-server maps) plus a v1 passthrough, and add cold-start rehydrate
round-trips for localPlaybackStore (entries) and offlineStore (albums).
Pin the contract of the three core-feature seams the refactor rests on: before
registration each delegator returns its documented default (mediaResolver
network-only, orbit neutral snapshot + allow, bridge null / no-op); after
registerX(fake) it forwards to the registered impl with the given args and
propagates its result. Underpins the boot smoke and the scenario suite.
hasLocalPlaybackUrl mirrors resolvePlaybackUrl's local-source branch so the
network guard can skip the reachability probe for local tracks; its only other
test mocks it away. Cover it directly against real stores: per-tier hits
(library / favorite-auto / ephemeral), the no-bytes false case, index-key vs raw
profile-id resolution, and an equivalence guard against resolvePlaybackUrl's
local branch that locks the bit-identical claim.
Import every lazy(() => import('@/features/*/pages/*')) page up front and assert
a default-exported component, so a broken specifier fails at CI instead of only
when the route is first navigated to. A drift guard reads the real route sources
and fails if the loader table and the app fall out of sync, so a newly added
route cannot silently skip coverage.
Guard the boot-order invariant the three core-feature seams rely on: importing
the real app entry module runs the registration side effects (playback bridge
via MainApp, media resolver via the offline barrel, orbit runtime via AppShell's
orbit barrel), and the smoke asserts each seam is the registered implementation,
not its neutral default. Drops any of those imports later, and the matching
assertion fails at CI instead of silently at runtime.
Each seam gains a small behavior-free isRegistered() introspection so the guard
reads registration directly rather than coupling to store internals.