mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-13 04:09:26 +00:00
Merge pull request #737 from got-feedback/feat/playlist-shuffle
feat(v3): playlist shuffle toggle
This commit is contained in:
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -6761,7 +6761,16 @@ window.feedBack.playQueue = (function () {
|
|||||||
if (!files.length) return false;
|
if (!files.length) return false;
|
||||||
list = files.slice(); idx = 0;
|
list = files.slice(); idx = 0;
|
||||||
source = (opts && opts.source) || '';
|
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) {
|
if (window.fbNotify) {
|
||||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-3
@@ -211,7 +211,12 @@
|
|||||||
'<div class="flex items-center justify-between mb-6 gap-3">' +
|
'<div class="flex items-center justify-between mb-6 gap-3">' +
|
||||||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
|
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
|
||||||
'<div class="flex gap-2 shrink-0 items-center">' +
|
'<div class="flex gap-2 shrink-0 items-center">' +
|
||||||
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>' : '') +
|
(pl.songs.length
|
||||||
|
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
|
||||||
|
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
|
||||||
|
'</button>' +
|
||||||
|
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
|
||||||
|
: '') +
|
||||||
(isSystem ? '' :
|
(isSystem ? '' :
|
||||||
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
|
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
|
||||||
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
|
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
|
||||||
@@ -226,6 +231,26 @@
|
|||||||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||||
'</div>';
|
'</div>';
|
||||||
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
|
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 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');
|
||||||
|
};
|
||||||
|
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
|
// 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
|
// 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
|
// without the queue, so the button always does something. An ALBUM plays
|
||||||
@@ -244,8 +269,8 @@
|
|||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
if (window.feedBack && window.feedBack.playQueue) {
|
if (window.feedBack && window.feedBack.playQueue) {
|
||||||
window.feedBack.playQueue.start(files, isAlbum
|
window.feedBack.playQueue.start(files, isAlbum
|
||||||
? { source: pl.name, arrangements: arrs }
|
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||||||
: { source: pl.name });
|
: { source: pl.name, shuffle: shuffleOn() });
|
||||||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||||
});
|
});
|
||||||
const listEl = root.querySelector('#v3-pl-songs');
|
const listEl = root.querySelector('#v3-pl-songs');
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user