From 425f72b33f80c9c3621253f89f8b382e8a4a5a24 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Fri, 3 Jul 2026 13:07:47 +0200 Subject: [PATCH 1/3] feat(v3): playlist shuffle toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossing-arrows toggle next to Play all / Play album on the playlist detail page. When on, playQueue.start Fisher-Yates-shuffles the queue once at start — on a copy, so the stored playlist order is untouched — swapping per-slot album arrangements in lockstep so each slot keeps its pinned arrangement (#685 contract preserved). Prev-less queue semantics are unchanged: auto-advance simply walks the shuffled order. Preference is global, persisted as localStorage v3PlaylistShuffle. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 + static/app.js | 11 +++- static/v3/playlists.js | 31 ++++++++++- tests/js/play_queue_shuffle.test.js | 86 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests/js/play_queue_shuffle.test.js 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..dc8b757 100644 --- a/static/v3/playlists.js +++ b/static/v3/playlists.js @@ -211,7 +211,12 @@ '
' + '

' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '

' + '
' + - (pl.songs.length ? '' : '') + + (pl.songs.length + ? '' + + '' + : '') + (isSystem ? '' : '' + (pl.cover_url ? '' : '') + @@ -226,6 +231,26 @@ : '

Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.

') + '
'; root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists); + // Shuffle toggle (crossing arrows, next to Play). Persisted globally — + // one preference, not per playlist. The queue is shuffled once when + // Play starts (playQueue.start's shuffle opt); the stored playlist + // order is never touched. + const shuffleBtn = root.querySelector('#v3-pl-shuffle'); + const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } }; + const paintShuffle = () => { + if (!shuffleBtn) return; + const on = shuffleOn(); + shuffleBtn.className = on + ? 'px-2 py-2 rounded-md bg-fb-primary hover:bg-fb-primaryHi text-white' + : 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text'; + shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off'; + shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + }; + paintShuffle(); + shuffleBtn?.addEventListener('click', () => { + try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ } + paintShuffle(); + }); // Play all: start the play-queue with this playlist's songs (auto-advances // track to track). Falls back to playing the first song on an older core // without the queue, so the button always does something. An ALBUM plays @@ -244,8 +269,8 @@ if (!files.length) return; if (window.feedBack && window.feedBack.playQueue) { window.feedBack.playQueue.start(files, isAlbum - ? { source: pl.name, arrangements: arrs } - : { source: pl.name }); + ? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() } + : { source: pl.name, shuffle: shuffleOn() }); } else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0])); }); const listEl = root.querySelector('#v3-pl-songs'); diff --git a/tests/js/play_queue_shuffle.test.js b/tests/js/play_queue_shuffle.test.js new file mode 100644 index 0000000..1e8de80 --- /dev/null +++ b/tests/js/play_queue_shuffle.test.js @@ -0,0 +1,86 @@ +// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE +// at start. Per-slot arrangements must swap in lockstep with their files +// (albums pass arrangements aligned by index, #685), the caller's arrays must +// not be mutated, and shuffle:false / absent must preserve order. Extract the +// playQueue IIFE from app.js and drive it against a playSong stub. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +function makeQueue() { + const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8'); + const start = src.indexOf('window.feedBack.playQueue = (function () {'); + assert.ok(start !== -1, 'playQueue IIFE found in app.js'); + const end = src.indexOf('})();', start); + assert.ok(end !== -1, 'playQueue IIFE terminator found'); + const iife = src.slice(start, end + 5); + const played = []; + const sandbox = { + window: { + feedBack: {}, + playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }), + fbNotify: null, + }, + }; + // eslint-disable-next-line no-new-func + new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent); + return { q: sandbox.window.feedBack.playQueue, played }; +} + +function drain(q, played) { + while (q.hasNext()) q.advance(); + return played.map((p) => p.fn); +} + +test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => { + const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak']; + const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i] + const origRandom = Math.random; + try { + // Deterministic RNG so the expected order is checkable. + let calls = 0; + const seq = [0.1, 0.9, 0.5]; + Math.random = () => seq[calls++ % seq.length]; + const { q, played } = makeQueue(); + q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true }); + const order = drain(q, played); + assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated + // Each played file carries the arrangement it started with. + played.forEach((p) => { + assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]); + }); + } finally { + Math.random = origRandom; + } +}); + +test('shuffle can change the order', () => { + const origRandom = Math.random; + try { + Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order + const { q, played } = makeQueue(); + q.start(['a', 'b', 'c'], { shuffle: true }); + const order = drain(q, played); + assert.notDeepStrictEqual(order, ['a', 'b', 'c']); + } finally { + Math.random = origRandom; + } +}); + +test('no shuffle opt preserves order and caller arrays are never mutated', () => { + const files = ['a', 'b', 'c']; + const arrs = [2, 0, 1]; + const { q, played } = makeQueue(); + q.start(files, { arrangements: arrs }); + assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']); + assert.deepStrictEqual(files, ['a', 'b', 'c']); + assert.deepStrictEqual(arrs, [2, 0, 1]); + + // shuffle:true must also leave the caller's arrays alone (start slices). + const { q: q2 } = makeQueue(); + q2.start(files, { arrangements: arrs, shuffle: true }); + assert.deepStrictEqual(files, ['a', 'b', 'c']); + assert.deepStrictEqual(arrs, [2, 0, 1]); +}); From a2f43009f7cdd0a7e1f4491f215905343cbfff5e Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Fri, 3 Jul 2026 14:08:54 +0200 Subject: [PATCH 2/3] fix(v3): match shuffle icon height to Play all button w-4 icon (16px) vs text-sm line-height (20px) made the shuffle button 4px shorter than its neighbor at equal py-2. Co-Authored-By: Claude Fable 5 --- static/v3/playlists.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/v3/playlists.js b/static/v3/playlists.js index dc8b757..72a0b47 100644 --- a/static/v3/playlists.js +++ b/static/v3/playlists.js @@ -213,7 +213,7 @@ '
' + (pl.songs.length ? '' + '' : '') + From 336132e04970089124e58af4585fb2f322b8cdf4 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Fri, 3 Jul 2026 14:10:16 +0200 Subject: [PATCH 3/3] fix(v3): keep shuffle toggle size stable across states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off state had a 1px border, on state none — toggling grew/shrank the button 2px and shifted the row. On state now carries a same-color border (border-fb-primary, already in the prebuilt CSS). Co-Authored-By: Claude Fable 5 --- static/v3/playlists.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/v3/playlists.js b/static/v3/playlists.js index 72a0b47..3d71289 100644 --- a/static/v3/playlists.js +++ b/static/v3/playlists.js @@ -241,7 +241,7 @@ if (!shuffleBtn) return; const on = shuffleOn(); shuffleBtn.className = on - ? 'px-2 py-2 rounded-md bg-fb-primary hover:bg-fb-primaryHi text-white' + ? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white' : 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text'; shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off'; shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');