* 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.
* 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).
* 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.
* fix(queue): persist timeline play history across queue replace (#1096)
Add a session-scoped play-history buffer (with play_session cold bootstrap)
and timeline UI that shows history + current + upcoming without mutating
the canonical queue or Subsonic sync.
* fix(queue): pin timeline current to top and replay history in-place
Timeline scroll matches queue mode (current at top). History clicks insert
after the playing track instead of replacing the queue, and replayed tracks
stay visible in the history strip.
* docs: add CHANGELOG and credits for timeline play history (PR #1204)
* fix(queue): resolve cross-server cover art for timeline history
Include album/cover ids in play_session bootstrap rows, prefetch history
refs through the queue resolver per server, and resolve before replay so
Now Playing artwork works for inactive-server tracks.
* fix(now-playing): stop playbackReport on cross-server track switch
Send stopped to the previous server's playbackReport session when the
playback server changes (queue click, history replay, etc.) so Who is
listening clears on the server that was showing the prior track.
* fix(queue): close timeline history review gaps for PR #1204
Defer play_session bootstrap until the library index is ready with retry
while timeline mode is active; resolve history and queue rows by serverId
+ trackId for mixed-server queues; add tests for bootstrap defer and ref lookup.
* chore(queue): remove dead timeline scroll guard in QueueList
Timeline scroll is handled in the virtual-rows effect; the legacy branch
is queue/playlist only.
* fix(queue): address PR review nits for timeline play history
Use authoritative row.ref.serverId for history clicks before resolver fill;
simplify empty bootstrap seed; tighten completion types; unify recent_plays SQL.
* fix(queue): immutable session history append for useSyncExternalStore
Replace in-place push with a fresh array so getSnapshot returns a new
reference and React re-renders on live appends without a playerStore update.
* 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
* refactor(cover): extract shared pickArtistBackdrop priority helper
* feat(hero): show the album artist's fanart as the mainstage hero backdrop
* feat(settings): configurable per-surface artist backdrop sources
Each artist-backdrop surface (mainstage hero, artist-detail header, fullscreen player) gets its own enable toggle + an ordered, individually-toggleable source list, configured under External Artwork Scraper on the Integrations tab (shown when the scraper is on). Reorder via the shared useDragSource/psy-drop drag infra plus keyboard-accessible up/down buttons; each source has its own on/off.
The shared chooser pickArtistBackdrop is generalised to resolveBackdrop + backdropFromConfig (ordered candidate list with the same pending/miss/centred-framing semantics), so all three surfaces resolve identically. themeStore persist bumped to v2; defaults reproduce today's order, so there is no visible change without user action.
Gating decoupled: the three surfaces are gated solely by their own per-surface flag, not by enableCoverArtBackground (which stays scoped to album/playlist-header cover blur). Reorder maths extracted to a pure, unit-tested module. i18n en + de (other locales TODO before PR).
Tests: resolver (10) + reorder (7).
* feat(cover): make the ensure queue surface-aware for artist backdrops
coverEnsureQueued now threads optional CoverEnsureOpts through to the Rust ensure and weaves the external surface into the in-flight key, so the fanart and banner surfaces of one artist no longer collide on one download chain. External surfaces also bypass the disk-src memory short-circuit (their {tier}-{surface}.webp never seeds those caches, and the canonical cover must not read as a hit). New thin ensureArtistBackdropQueued wrapper. Backward-compatible: plain covers append nothing to the key; the 5 queue tests stay green.
* fix(cover): reset the artist external-image hook synchronously on artist change
The hook reset src in an effect (one render late), so for the render between an artist change and that effect a consumer read the *previous* artist's resolved image. The mainstage hero then froze (and cached into per-album memory) a neighbouring slide's banner. Reset synchronously via the React adjust-state-on-prop-change pattern. Also removes brief stale-image flashes on the artist-detail header and fullscreen player.
* feat(hero): prefetch artist backdrops and show-ready-now / upgrade-on-re-entry
warmHomeMainstageCovers now prefetches each hero slide's artist backdrop (banner/fanart) at static slide-index priorities (idx1=high, idx0=low, rest=middle; no reprioritise on navigation), then predecodes every slide already on disk. useHeroBackdrop shows the best source ready at entry (Navidrome on a cold first visit, the prefetched/cached external one on re-entry) and freezes that source choice for the visit so nothing swaps mid-dwell; the url is derived live from the frozen choice, with a per-album disk memory for re-entry. HeroBg now crossfades only after the image bytes load (onLoad/complete gate + onError + fallback). Inert when the scraper is off. Tests: per-album memory (5).
* docs(changelog): configurable artist backdrops + mainstage hero (PR #1193)
CHANGELOG Added entry, settingsCredits line, and the What's New Artist-artwork highlight extended to cover the mainstage hero backdrop and per-place source config.
* docs(changelog): fold mainstage hero + per-place backdrops into the fanart entry (PR #1193)
Merge the configurable-backdrops changes into the existing 'Artist artwork from fanart.tv' block (now PR #1137 and #1193) instead of a separate entry.
* fix(hero): revert HeroBg load-gate that blanked the app (Maximum update depth)
The byte-load gate I added drove the crossfade reveal from an inline img ref + onLoad that re-fires on every render and scheduled a setTimeout each call; frequent re-renders (playback, marquee) stacked nested updates until React threw 'Maximum update depth exceeded' — and with no ErrorBoundary the whole window blanked. Reverted HeroBg to the proven timer-based crossfade. The hero only ever receives ready/predecoded urls, so the gate was cosmetic.
* fix(hero): gate the HeroBg crossfade on image load to stop the slide flicker
The bare 20 ms reveal faded a layer in before its bytes were ready (notably the Navidrome raw url), so a slide change flickered. Now an Image() preloader in the [url] effect reveals the layer on load (or a cached complete check), with a fallback. Everything is scheduled once per url — no per-render <img> ref/onLoad — so unlike the reverted gate it can't stack nested updates.
* feat(i18n): translate the backdrop-source settings into the remaining 10 locales
Adds the per-surface backdrop config strings (es, fr, nl, zh, nb, ru, ro, ja, hu, pl) and extends externalArtworkDesc to mention the mainstage hero where the key exists (ja has none → falls back to en).
* 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)
* feat(sort-dropdown): support a right-aligned popover via align prop
* feat(playlist): sort playlist tracks by date added
* docs(changelog): playlist sort by date added (#1191)
* docs(credits): playlist sort by date added (#1191)
* feat(album): show all genres in album details, linkable to genre pages
The album header listed only a single genre. It now renders every genre
from the album's OpenSubsonic genres[] (falling back to splitting the
legacy genre string), each linking to its genre page via the existing
genre route.
* docs(changelog): multiple genres in album details (#1186)
* refactor(genres): extract genreColor into a shared util
* feat(album): union album and track genres behind a +N cursor menu
* feat(album): read album detail from the library index when ready
* docs(changelog): expand the album genres entry
* feat(album): genre pill row with keyboard-navigable popover
* test(offline): cover index-first resolveAlbum path with mocked libraryIsReady
* refactor(styles): move album-detail genre styles out of cover-lightbox.css
* fix(settings): box the AutoDJ and Hi-Res sub-options like Normalization
The AutoDJ overlap-cap and the Hi-Res blend-rate options sat bare in their
sections instead of in the bordered sub-card the Normalization block uses for
its per-mode controls. Wrap both (and the crossfade-seconds slider, for
consistency within Track transitions) in the shared settings-norm-block card,
and drop the redundant SettingsGroup wrapper from the Hi-Res block — the Audio
tab already wraps that section in a group, so the block added a second border.
* docs(changelog): audio sub-section design fix; move recent entries to the end of Fixed
The #1172 and #1174 entries were prepended to the Fixed section instead of
appended; move them to the end where new entries belong, and add the #1175
audio settings entry.
* fix(playlist): play the playlist from the context menu Play Now action
The Playlists-page context menu 'Play Now' only navigated to the playlist
detail page instead of starting playback. It now loads the playlist's songs
and plays them via the shared playPlaylistAll action (same path as the detail
page 'Play All' button), with a guard against load failures.
* fix(ui): close the context menu on window resize
The context menu is absolutely positioned at fixed coordinates, so resizing
the window left it stranded and drifting off-screen. Whether a resize closed
it was inconsistent across setups (it stayed open on some Windows and Linux
environments). Always close it on resize so the behaviour is the same
everywhere.
* docs(changelog): context menu Play Now and resize fixes (#1174)
* docs(changelog): credit the reporter for the context menu fixes
* fix(cover): report a genuine miss for external artist surfaces
When external artwork is enabled, the fanart/banner ensure fell through to
the Navidrome cover and returned it as a hit on a fanart.tv miss. That made
'this artist has no banner' indistinguishable from a real hit, so the
artist-detail hero short-circuited on the ND cover and never reached the
fanart it actually had. Return hit=false on an external-surface miss instead;
the Navidrome fallback is the caller's job, since each surface has its own
chain (hero: banner->fanart->ND, fullscreen player: fanart->ND).
* fix(artist): hero background banner->fanart->Navidrome with raised focal point
The hero header background now steps through banner -> fanart -> Navidrome
artist cover, resolving the ND cover the same way the fullscreen player does.
Previously it had no Navidrome stage at all and, combined with the backend
masking a banner miss as a hit, showed the ND cover where the fanart belonged.
Also raise the focal point (background-position: center 30%) for the portrait-ish
fanart/ND images so the band's heads stay in frame on wide (2K+) viewports,
where 'cover' scales them up and overflows vertically. The wide banner strip
keeps the shared center. Done via a scoped inline style; .album-detail-bg stays
untouched for the album/playlist headers that share it.
* docs(changelog): artist header external-background fallback fix (#1172)
* fix(macos): pad dock icon to Apple icon grid
The macOS .icns artwork filled the canvas edge-to-edge, so the dock icon
rendered larger than native apps. Replace icon.icns with a build whose artwork
is scaled to an ~824px body centred on a 1024px transparent canvas (Apple's
icon grid). macOS-only asset; Windows .ico and Linux PNGs unchanged. Refs #1166.
* fix(macos): skip the icns app-icon in dev to avoid a launch crash
Tauri's dev-only macOS path sets the app icon from icon.icns via
NSImage::initWithData(...).expect(...) (RunEvent::Ready); the padded icns
makes that return nil and abort at launch — release builds are unaffected.
Add a dev config override (tauri.dev.conf.json) that drops icon.icns from
bundle.icon so the dev app icon falls back to a PNG; tauri:dev passes it via
--config. Production bundling keeps the padded icon.icns. Refs #1166.
* docs(changelog): add entry for macOS dock icon padding (PR #1169)
* 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>
* 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.
* fix(orbit): harden the guest outbox — recover lost suggestions, avoid a duplicate outbox
Two outbox robustness issues on the poll-based, non-atomic playlist
transport:
- Lost-update: a track a guest appends to its outbox can be wiped by a
concurrent host sweep-clear before the host recorded it, leaving the
suggestion lost AND stuck on "waiting on host" forever (it only clears
once it reaches the host's play queue). The playlist read-modify-write
can't be made atomic, so recover: re-send a pending suggestion the host
hasn't recorded (absent from state.queue) past a grace window — the host
dedupes by (user, trackId), so it's idempotent — and give up + toast past
a 45s window so the row stops hanging. New pendingResend planner module
with unit tests; suggest + tick share ensureTrackInOutbox.
- Duplicate outbox: a transient getPlaylists failure at join was swallowed
to [], so the existing-outbox check missed and a second outbox was
created. Distinguish "lookup failed" from "genuinely absent" and retry
once before falling back to create.
New i18n key for the give-up toast across all locales.
* fix(orbit): reject non-http(s) schemes in share invites
normalizeShareServerUrl only prepends http:// when the server string doesn't
already start with "http", so an invite carrying a scheme like httpx:// or
https-phish:// slips through unchanged. Reject anything whose parsed protocol
isn't http(s) at parse time, before the known-server match. Add round-trip
tests covering the accepted and rejected schemes.
* fix(orbit): read exit-modal role fresh to avoid a stale keydown closure
The exit modal's Enter/Escape handler binds once per open (deps [isOpen]) and
closed over the role prop, so a role change while the modal was up would act
on a stale value. Read role from the store inside the action instead, and drop
the now-unused reactive subscription.
* docs(changelog): consolidate Orbit fixes into one block, add 1159
* feat(orbit): mirror host track-transition settings into the session
Each client previously used its own local crossfade / gapless / AutoDJ
settings, so guests blended tracks differently from the host and drifted
out of sync — the exact drift the Catch-Up button exists to paper over.
Add an optional OrbitSettings.transitions (crossfade enabled/secs/trim,
AutoDJ smooth-skip, gapless), additive on the wire so older sessions
simply omit it. The host seeds its own prefs at start and refreshes them
every tick, so a mid-session change propagates. Guests snapshot their own
prefs on join, adopt the host's for the session (idempotent — no setState
churn / audio-sync re-fire when unchanged), and restore their own on
leave. Applying via authStore.setState reuses the existing authSyncListener
path to the Rust engine, so no new IPC. Without the Hot cache a guest's
AutoDJ blend degrades gracefully via the existing byte-preload fallback.
Add a bridge module with unit tests for read/apply/save/restore.
* feat(orbit): lock guest transition controls during a session
Since a guest now mirrors the host's track-transition settings (re-applied
every read tick), their own controls would visibly fight the sync. Disable
them while a guest is in a live session and explain why.
Settings -> Audio -> Track transitions greys out the mode picker, seconds
slider and smooth-skip toggle with a "controlled by the Orbit host" note;
the queue toolbar's Gapless / Crossfade / AutoDJ quick-toggles get the same
disabled state and tooltip. Host controls are untouched. New i18n key added
across all locales.
* docs(changelog): add Orbit transition sync (1158)
* fix(orbit): unify session-settings default to the canonical preset
updateOrbitSettings merged patches onto a hand-rolled
{ autoApprove: true, autoShuffle: true } fallback when a legacy session's
blob predated the settings field. That literal disagreed with
ORBIT_DEFAULT_SETTINGS (autoApprove: false), so toggling autoShuffle on
such a session silently flipped autoApprove on, landing guest suggestions
without host approval. Use ORBIT_DEFAULT_SETTINGS as the single source of
truth for the fallback. Add a behavioural test for the settings-less,
preserve-existing, and not-hosting paths.
* fix(orbit): enforce maxUsers host-side in the participant rebuild
The join gate only sees a one-tick-old state blob, so two guests can both
pass the participants.length < maxUsers check and join past the cap at
once; maxUsers was advisory. The host is the single writer of the
canonical state, so enforce the cap there: admit at most maxUsers guests
in the participant rebuild, earliest joiners winning (an established
participant is never displaced by a newcomer) with a deterministic
username tie-break for same-tick joins. Suggestions from an over-cap guest
are ignored, consistent with their absence from the participant list.
Reorders the fold so the admitted set gates the queue-additions loop. Add
tests for the cap, the no-displacement rule, and the ignored over-cap
suggestion.
* fix(orbit): serialise host state pushes to stop overlapping ticks dropping writes
pushState does several awaited round-trips (sweep -> resolve -> write) and
is fired from three sources: the mount, the 2.5 s timer, and a play/pause
subscription. With no in-flight lock these can overlap and race
last-writer-wins: a slow run that already swept and cleared an outbox can
lose its write to a faster run, dropping those suggestions from both the
outbox and the published state. The lastPushedAtRef was only ever written,
never used as a guard.
Add makeCoalescedRunner: a pure helper that serialises an async task and
coalesces any mid-flight request into a single rerun, so no trigger is
lost. The host hook wraps pushState in it; the dead ref is removed. Add a
unit test for the serialise + coalesce semantics.
* docs(changelog): add Orbit host-side session guards (1157)
* fix(orbit): stop cleanup sweep from deleting live sessions on other devices
The orphan-cleanup regex anchored the trailing `__` inside the optional
`_from_…` group, so the canonical session playlist name
(`__psyorbit_<sid>__`) never matched. It then fell into the
"unrecognised → prune" branch, which deletes unconditionally and bypasses
the orphan-TTL grace that exists precisely to protect a session running on
the user's other device. Opening Psysonic on a second device wiped the
first device's live session out from under its guests.
Move the trailing `__` outside the optional group so both the session and
outbox names match; the existing sid/TTL/ended logic then applies as
intended. Add a behavioural test covering keep-fresh / prune-stale /
prune-ended / skip-current / outbox / corrupt / foreign-owner cases.
* fix(orbit): keep host publishing when the state blob grows past budget
OrbitState.queue (the suggestion/attribution history) was append-only at
the sweep-fold step and never bounded. On a long session it grew until the
serialised blob exceeded ORBIT_STATE_MAX_BYTES; serialiseOrbitState then
threw, writeOrbitState's caller swallowed the error and retried the same
over-budget state every tick, so the host silently stopped publishing and
guests froze into a host-timeout.
Two layers:
- Cap queue at ORBIT_QUEUE_HISTORY_LIMIT in applyOutboxSnapshotsToState,
evicting oldest-by-addedAt (robust to the periodic queue shuffle). The
dropped tracks have long since played, so their attribution/dedupe entry
is dead weight.
- Add serialiseOrbitStateForWire: a budget-aware serialiser that sheds
oldest history, then the play-queue tail, on a copy before giving up.
writeOrbitState now uses it, so a transient over-budget tick degrades the
published blob instead of taking the host offline. Local store state is
untouched.
Tested: history cap eviction order, wire-trim byte budget + oldest-first
drop, play-queue fallback, and within-budget passthrough.
* fix(orbit): lock out the proactive radio top-up during a session
The ≤2-remaining radio top-up in runNext lacked the isInOrbitSession()
guard that its infinite-queue sibling has at both entry and resolution.
A guest who joined with residual radioAdded tracks (before the first
syncToHost replaces the queue) could fire it, appending up to 10 unrelated
radio tracks and trimming queue history — drifting the guest off the host's
playlist.
Add the guard at entry and re-check inside the resolution .then(), mirroring
the infinite-queue branch; the existing finally() still clears the fetching
flag. The queue-exhausted radio fallback already returns early in Orbit, so
only this mid-queue path was unguarded.
* docs(changelog): add Orbit cleanup / state-budget / radio fixes (#1155)
* docs(changelog): move Orbit fixes to bottom of Fixed section (#1155)
* fix(playlists): wrap header action buttons instead of overflowing
The Playlists header action row was a fixed flex row, so at narrow
widths (e.g. with the queue panel open) the buttons overflowed and
were clipped off-screen. Add flex-wrap so they wrap, left-aligned,
matching the Albums toolbar behaviour.
* docs(changelog): add 1.49.0 entry for Playlists header button wrap (#1153)
* feat(i18n): add Hungarian (hu) translation files
* feat(i18n): register Hungarian locale and language option
* docs(changelog): Hungarian translation entry and credit (#1149)
* fix(i18n): list Romanian in the Login language picker
* fix(eq): surface the active AutoEQ profile name in the preset picker
* i18n(settings): add AutoEQ preset group label (10 locales)
* docs(changelog): AutoEQ active-profile picker fix (#1147)