diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47ed7da..f20cda0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
+
### Changed
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
diff --git a/static/app.js b/static/app.js
index c9c5ed7..0a46264 100644
--- a/static/app.js
+++ b/static/app.js
@@ -6761,7 +6761,16 @@ window.feedBack.playQueue = (function () {
if (!files.length) return false;
list = files.slice(); idx = 0;
source = (opts && opts.source) || '';
- arrangements = (opts && opts.arrangements) || null;
+ arrangements = (opts && opts.arrangements) ? opts.arrangements.slice() : null;
+ if (opts && opts.shuffle && list.length > 1) {
+ // Fisher-Yates, once at start. Swap arrangements in lockstep so an
+ // album slot's pinned arrangement stays glued to its file (#685).
+ for (let i = list.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [list[i], list[j]] = [list[j], list[i]];
+ if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
+ }
+ }
if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
}
diff --git a/static/v3/playlists.js b/static/v3/playlists.js
index 2491936..3d71289 100644
--- a/static/v3/playlists.js
+++ b/static/v3/playlists.js
@@ -211,7 +211,12 @@
'