Auto previously passed `undefined` to `toLocaleTimeString`, which falls
back to the JS engine's default locale. Inside WebKitGTK that resolves
to `en-US` for many users regardless of `LC_TIME`, so the queue ETA and
sleep-timer preview rendered 12h AM/PM even when the OS clock was 24h.
Pass `i18n.language` instead, matching the convention already used in
the theme-scheduler hour picker (AppearanceTab.tsx). Explicit 12h/24h
choices still override.
* feat(settings): clock format setting (Auto / 24h / 12h)
Reported on the Psysonic Discord — the Queue side panel's ETA label
and the sleep-timer preview both render via `formatClockTime`, which
just calls `toLocaleTimeString` and so follows the user's system
locale. On en-US that means AM/PM, with no in-app way out.
Add a tri-state **Clock Format** setting under
**Settings → System → App Behavior**:
* `auto` (default) — keep the existing locale-driven behaviour, so
bestehende installs are unaffected on first launch.
* `24h` — force 24-hour wall-clock output everywhere
`formatClockTime` is used.
* `12h` — force AM/PM output.
Wired through `authStore` (`clockFormat`, `setClockFormat`), exposed
via `CustomSelect` in `SystemTab`, and threaded into the two
consumers (`QueueHeader`, `PlaybackDelayModal`) so they re-render on
change. `formatClockTime` itself stays a pure helper — it accepts the
setting as an optional second argument and maps it to `hour12`.
Locale coverage: all nine bundled locales (en, de, es, fr, nl, nb,
ru, zh, ro) get the four new settings strings. Pin tests added for
the `setClockFormat` setter and the `hour12` mapping in
`formatClockTime`.
* docs(changelog): clock format setting + contributors (PR #742)
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix
Defer seekbar/progress until HTTP stream is armed for both legacy and
RangedHttpSource; show buffering overlay on cover art. Add MP4 tail
prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so
moov-at-end M4A can start without reading the full mdat.
* feat(hot-cache): spill large ranged streams to disk for promote
When a ranged HTTP download completes above the 64 MiB RAM promote cap,
write the existing buffer once to app-data stream-spill/ and register it
for hot-cache promote (rename) and replay via fetch_data. Analysis seeds
from the spill file up to the local-file cap (512 MiB).
* fix(ui): stream buffering — grayscale cover and static clock icon
Desaturate player and queue cover art while isPlaybackBuffering; keep a
non-animated clock overlay for visibility without the spinning animation.
* fix(playback): review follow-up — tests, i18n, spill cleanup, changelog
Clippy and test layout fixes; stream spill orphan cleanup on startup;
buffering flag guard in progress handler; bufferingStream in all player
locales; CHANGELOG and contributor credits for stream/M4A work.
* docs: attribute stream buffering and M4A streaming to PR #737
* test(audio): avoid create_engine in stream spill unit test
CI runners have no audio output device; test spill take/consume via
the Mutex slot only, matching install_stream_completed_spill tests.
* fix(playback): pin queue streams, cover art, and library links to queue server
When the active server changes while a queue from another server is playing,
keep streams and UI on queueServerId; switch back for artist/album links and
queue or player-bar context menus.
* fix(playback): switch to queue server when opening Now Playing
Ensure active server matches queueServerId before Subsonic fetches on the
Now Playing page, mobile player route, and queue info panel; scope caches
by server id.
* docs(credits): mention Now Playing in PR #717 contribution line
* fix(playback): route scrobble and queue sync to queue server
Address PR review: apiForServer for scrobble/now-playing/savePlayQueue,
clear queueServerId on server removal, mini-player queueServerId sync,
block cross-server enqueue with toast, and regression tests.
Findings 5-8 of the dedup audit:
- F5 byte formatters: appUpdaterHelpers.fmtBytes + ZipDownloadOverlay
.formatMB route through the existing formatBytes; a new formatMb
(always-MB) backs playlistDetailHelpers.formatSize, AlbumHeader and
the 4 inline DeviceSyncPreSyncModal expressions. SongInfoModal.format
Size is intentionally left — it uses decimal (1e6) divisors, not 1024.
- F6 sanitizeHtml: extracted to utils/sanitizeHtml.ts; AlbumHeader,
ComposerDetail and the (now-empty, deleted) artistDetailHelpers use it
directly. nowPlayingHelpers keeps its own export but now delegates to
the shared sanitiser and only adds its trailing-link strip on top.
- F7 album duration: BecauseYouLikeRail's formatAlbumDuration drops in
favour of the shared formatHumanHoursMinutes. Behaviour note: total
minutes now floor instead of round (<=1 min display difference,
matches every other caller).
- F8 clock time: extracted to utils/format/formatClockTime.ts;
PlaybackDelayModal + QueueHeader use it (toLocaleTimeString and
Intl.DateTimeFormat produced identical output).
Behaviour preserved except the two explicitly noted divergences (F7
round->floor; F5 appUpdater/Zip now show GB above 1 GB instead of a
large MB number).
The mm:ss track-time formatter was hand-rolled in 11 places and the
h:mm:ss total-duration formatter in 4 — extract two tested functions:
- formatTrackTime(seconds, fallback='0:00') — m:ss, used for track /
playback times. fallback param covers the '–' placeholder rows.
- formatLongDuration(seconds) — h:mm:ss when >=1h, else
m:ss, used for album / queue totals.
Behaviour preserved per call site: the unified guard
(!seconds || !isFinite || <0 -> fallback) produces identical output to
every prior variant for all real inputs; SongRow keeps its '–' via the
fallback arg. Removes the formatter exports from 6 componentHelpers
files (playerBarHelpers / fullscreenPlayerHelpers deleted — they only
exported the formatter) and 7 inline component copies.
+ formatDuration.test.ts
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.
Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
* refactor(queue-panel): H4 — extract helpers + Save/LoadPlaylistModal
Pure code-move: formatTime, formatQueueReplayGainParts, renderStars and the
DurationMode type → utils/queuePanelHelpers.tsx; the two playlist modals → own
files under components/queuePanel/.
QueuePanel.tsx: 1256 → 1104 LOC.
* refactor(queue-panel): H4 — extract QueueHeader
Pure code-move: the title/count/duration/collapse-button header → its own
component file. No prop or behaviour changes.
QueuePanel.tsx: 1104 → 1004 LOC.
* refactor(queue-panel): H4 — extract QueueCurrentTrack + QueueLufsTargetMenu
The currently-playing track block (cover, info, replay-gain / LUFS badge with
its target-listbox portal) moves into two own files. Pure code-move via prop
plumbing. setLoudnessTargetLufs is typed as LoudnessLufsPreset throughout the
new components.
QueuePanel.tsx: 1004 → 812 LOC.
* refactor(queue-panel): H4 — extract useQueuePanelDrag hook
Moves the psy-drag wiring (hit-test registration, drop-inside dispatch
for song/songs/album/queue_reorder payloads, drop-outside removal) into
its own hook. Drops the dead isRadioDrag variable since the
parsedData.type === 'radio' guard inside onPsyDrop already handles that
case.
QueuePanel.tsx: 812 → 715 LOC.
* refactor(queue-panel): H4 — extract useQueueLufsTgtPopover hook
Pulls the LUFS-target popover open-state, button/menu refs, fixed-position
recompute on open/resize/scroll, and auto-close-when-RG-collapses out of
QueuePanel.
QueuePanel.tsx: 715 → 662 LOC.
* refactor(queue-panel): H4 — extract QueueToolbar
The toolbar-button switch (shuffle/save/load/share/clear/gapless/crossfade/
infinite) and the crossfade popover (with its close-on-outside-click effect)
move into one component. crossfadeBtnRef / crossfadePopoverRef and
showCrossfadePopover state are now component-local — QueuePanel no longer
sees them.
QueuePanel.tsx: 662 → 541 LOC.
* refactor(queue-panel): H4 — extract QueueList
The OverlayScrollArea + queue.map block (with track rows, lucky-mix dice
overlay, and radio/auto-added section dividers) moves into its own
component. PlayerState['contextMenu'] + PlayerState['playTrack'] are
re-used for prop typing, the local StartDrag alias matches the
DragDropContext signature.
QueuePanel.tsx: 541 → 434 LOC.
* refactor(queue-panel): H4 — extract QueueTabBar + useQueueAutoScroll, final cleanup
QueueTabBar is the bottom queue/lyrics/info tab switcher. useQueueAutoScroll
groups the three list-scroll effects (publish scrollTop reader, restore
pending snapshot, scroll next track into view on advance). Drops the dead
toggleQueue and replayGainMode selectors plus the now-unused Play, Radio,
MicVocal, ListMusic, Info imports and OverlayScrollArea.
QueuePanel.tsx: 434 → 383 LOC. Every new file under 400.