Folder Library: enable hover preview by default

Make the hover-preview feature opt-out instead of opt-in (defaults to on). Increase dwell delay from 500ms to 800ms to prevent accidental triggers while clicking/dragging. Replace 4-bar equalizer indicator with 9-bar waveform with staggered animation and fade-in entrance. Add guards to prevent preview during drag operations. Update button styling and all user-facing docs to reflect new defaults.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
This commit is contained in:
Kyle
2026-07-23 00:51:14 -04:00
parent 9f8e1e6f61
commit ad9ad229c9
4 changed files with 44 additions and 27 deletions
+5 -6
View File
@@ -54,12 +54,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
loaded as fretted arrangements — the loader's file/notation gate keeps a drum loaded as fretted arrangements — the loader's file/notation gate keeps a drum
part out of the fretted pipeline (and out of note-detection grading), pinned part out of the fretted pipeline (and out of note-detection grading), pinned
by test. Legacy single-drum packs read exactly as before, as a one-part list. by test. Legacy single-drum packs read exactly as before, as a one-part list.
- **Folder Library — preview on hover.** The Folders library view gains an
optional ▶ toolbar toggle (off by default): hovering a song ~0.5s previews
its audio in place, with an equalizer indicator over the artwork, and stops
on leave. Uses a dedicated `<audio>` element (never the main player) and
falls back across the pack's audio members so every song previews. The
preference persists per surface in `localStorage`.
- **`chart-transform` capability domain (#952)** — plugins can now remap the - **`chart-transform` capability domain (#952)** — plugins can now remap the
chart before rendering and scoring through a core-owned provider chart before rendering and scoring through a core-owned provider
coordinator. Synchronous transforms run after difficulty filtering; host coordinator. Synchronous transforms run after difficulty filtering; host
@@ -189,6 +183,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
at `default: off`, drops the key); the fallback and the aliases are removed once at `default: off`, drops the key); the fallback and the aliases are removed once
they are migrated (#945). they are migrated (#945).
- **Folder Library previews on hover, like the grid and list views.** The Folders
view's cards and rows now carry the standard `data-fn` / `data-v3-play` markup,
so the existing **Song Preview** plugin previews them on hover exactly like the
other views (same audio, same behaviour) — Folder Library ships no preview code
of its own.
### Added ### Added
- **Genres fall back to MusicBrainz enrichment** — the effective genre now - **Genres fall back to MusicBrainz enrichment** — the effective genre now
+16 -3
View File
@@ -153,13 +153,15 @@ Each song object (built by `_meta()`):
"added": 1748132400.0, "added": 1748132400.0,
"arrangements": ["Lead", "Rhythm", "Bass"], "arrangements": ["Lead", "Rhythm", "Bass"],
"stems": ["Drums", "Bass", "Vocals"], "stems": ["Drums", "Bass", "Vocals"],
"lyrics": true "lyrics": true,
"audio_member": "preview.ogg"
} }
``` ```
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`. - `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit. - `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects. - `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
- `audio_member` is the best in-pack audio file for **hover-preview**, resolved by `_audio_member()` (`preview.ogg``stems/full.ogg``stems/audio.mp3` → any stem; `null` if the pack has no audio). Fetch it as `/api/sloppak/<filename>/file/<audio_member>` (both path-encoded per segment). Resolving it server-side means the frontend previews with **one** request instead of probing (and 404ing) a hardcoded path. Cached with the rest of the meta.
### extract_meta returns arrangements/stems as objects, not strings ### extract_meta returns arrangements/stems as objects, not strings
@@ -329,13 +331,24 @@ Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTM
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song) - **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
- **Enter confirms** — submits, equivalent to OK - **Enter confirms** — submits, equivalent to OK
## Preview on Hover
Hovering a song for `_HOVER_PREVIEW_DELAY_MS` (800 ms) plays a short audio preview in place — no navigation to the player. Toggled by a play-icon toolbar button (`_injectToolbar`); **on by default** (`_previewHover`, persisted per surface under `<cfg.storePrefix>previewHover` — only an explicit stored `'false'` disables it).
- **Audio** — a dedicated `_previewAudio` `<audio>` element (never the main player's). Source is `song.audio_member` (backend-resolved; see Song Metadata) fetched from `/api/sloppak/<file>/file/<member>`, played **from 0**. Don't seek by `song.duration` — the member is usually a short `preview.ogg` clip, so a full-song offset lands past its end and nothing plays.
- **Sequence guard** — `_previewSeq` is bumped on every start/stop, so a slow load that resolves after the pointer has moved on is ignored.
- **Drag / click safety** — `mouseenter` skips arming while a drag is in progress (`_dragState` non-null); `mousedown` clears the pending dwell timer **and** `_stopPreview()`s any already-playing one, so grabbing a song to drag (or clicking to play) never leaves a preview running.
- **Indicator** — a waveform overlay (`.fl-wf`, 9 bars) over the art, drawn via a one-time injected `<style>` (`_ensurePreviewStyle`). It **fades in** (`fl-in`) to avoid an abrupt pop, and the bars animate only once audio actually fires (the `.playing` class, set on the `playing` event). Perf: bars animate with `transform: scaleY` + `will-change: transform` (GPU-composited, no per-frame JS), only while playing. `_showIndicator` / `_markIndicatorPlaying` / `_clearIndicator` manage it against `_previewIndHost`.
- **CSS gotcha** — set the bar animation with **longhand** `animation-name`/`-duration`/… , not the `animation` shorthand: the shorthand resets `animation-delay` to 0 and (being higher-specificity) overrode the per-bar `nth-child` delays, making every bar move in sync.
Changing `screen.js` needs a re-fetch: the host loads it as `screen.js?v=<plugin.json version>`, so **bump `plugin.json` `version`** to ship a change to users (or hard-refresh while developing).
## Roadmap ## Roadmap
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache. Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, the warm metadata cache, and **preview-on-hover** (see above).
Not yet implemented, in rough priority order: Not yet implemented, in rough priority order:
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
- **Bulk move** — multi-select songs and move them all at once. - **Bulk move** — multi-select songs and move them all at once.
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries. - **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows. - **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
+2 -2
View File
@@ -30,7 +30,7 @@ A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC s
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid - **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
- **Album art** — pulls art automatically for every song in both views - **Album art** — pulls art automatically for every song in both views
- **One-click playback** — click any song to start playing immediately - **One-click playback** — click any song to start playing immediately
- **Preview on hover** — optional: hover a song briefly to preview its audio in place; toggle on/off from the toolbar (off by default) - **Preview song on hover** — hover a song briefly to preview its audio in place, with a waveform indicator over the art; toggle from the toolbar (on by default)
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle - **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support - **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
- **Folder management** — create, rename, and delete folders without leaving the plugin - **Folder management** — create, rename, and delete folders without leaving the plugin
@@ -55,7 +55,7 @@ Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`),
| Switch to grid view | Click the grid icon in the toolbar | | Switch to grid view | Click the grid icon in the toolbar |
| Switch to list view | Click the list icon in the toolbar | | Switch to list view | Click the list icon in the toolbar |
| Play a song | Click any song row or card | | Play a song | Click any song row or card |
| Preview on hover | Click the toolbar button to toggle; when on, hovering a song ~0.5s previews its audio | | Preview song on hover | On by default; click the toolbar button to toggle. When on, hovering a song ~0.8s previews its audio |
| Sort songs | Use the sort dropdown in the toolbar | | Sort songs | Use the sort dropdown in the toolbar |
| Toggle sort direction | Click the arrow button next to the sort dropdown | | Toggle sort direction | Click the arrow button next to the sort dropdown |
| Open filters | Click the filter icon in the toolbar | | Open filters | Click the filter icon in the toolbar |
+21 -16
View File
@@ -50,7 +50,7 @@ function createFolderSurface(cfg) {
let _sortDir = _store('sortDir') || 'asc'; let _sortDir = _store('sortDir') || 'asc';
let _toolbarDone = false; let _toolbarDone = false;
let _hoveredFolder = null; // { wrap, hdr, btnGroup } — only innermost folder is active let _hoveredFolder = null; // { wrap, hdr, btnGroup } — only innermost folder is active
let _previewHover = _store('previewHover') === 'true'; // opt-in: preview a song's audio on hover let _previewHover = _store('previewHover') !== 'false'; // default on; preview a song's audio on hover
let _previewTimer = null; let _previewTimer = null;
let _previewAudio = null; // dedicated element — never touches the main player's <audio> let _previewAudio = null; // dedicated element — never touches the main player's <audio>
let _previewSeq = 0; // invalidates in-flight loads when the hover moves or stops let _previewSeq = 0; // invalidates in-flight loads when the hover moves or stops
@@ -740,7 +740,7 @@ function createFolderSurface(cfg) {
// pointer dwells briefly (so skimming doesn't blast audio), stopping on // pointer dwells briefly (so skimming doesn't blast audio), stopping on
// leave. Shows an equalizer "now playing" indicator over the art. Never // leave. Shows an equalizer "now playing" indicator over the art. Never
// calls playSong or touches the main player's <audio> element. // calls playSong or touches the main player's <audio> element.
var _HOVER_PREVIEW_DELAY_MS = 500; var _HOVER_PREVIEW_DELAY_MS = 800; // dwell before preview — long enough that a click/drag doesn't trigger it
var _previewIndHost = null; // art element currently showing the indicator var _previewIndHost = null; // art element currently showing the indicator
// The backend resolves the correct in-pack audio member (song.audio_member), // The backend resolves the correct in-pack audio member (song.audio_member),
@@ -757,13 +757,14 @@ function createFolderSurface(cfg) {
var st = document.createElement('style'); var st = document.createElement('style');
st.id = 'fl-preview-style'; st.id = 'fl-preview-style';
st.textContent = st.textContent =
'.fl-preview-ind{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);z-index:4;pointer-events:none;}' + '.fl-preview-ind{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);z-index:4;pointer-events:none;animation:fl-in .28s ease both;}' +
'.fl-eq{display:flex;align-items:flex-end;gap:2px;height:45%;max-height:22px;opacity:.55;}' + '.fl-wf{display:flex;align-items:center;gap:2px;height:55%;max-height:22px;opacity:.55;transition:opacity .2s ease;}' +
'.fl-preview-ind.playing .fl-eq{opacity:1;}' + '.fl-preview-ind.playing .fl-wf{opacity:1;}' +
'.fl-eq i{display:block;width:3px;height:25%;background:#60a5fa;border-radius:1px;}' + '.fl-wf i{display:block;width:2px;height:100%;background:#60a5fa;border-radius:2px;transform:scaleY(.4);transform-origin:center;}' +
'.fl-preview-ind.playing .fl-eq i{animation:fl-eq-b .8s ease-in-out infinite;}' + '.fl-preview-ind.playing .fl-wf i{animation-name:fl-wf;animation-duration:1s;animation-timing-function:ease-in-out;animation-iteration-count:infinite;will-change:transform;}' +
'.fl-eq i:nth-child(2){animation-delay:.15s;}.fl-eq i:nth-child(3){animation-delay:.3s;}.fl-eq i:nth-child(4){animation-delay:.45s;}' + '.fl-wf i:nth-child(odd){animation-delay:-.2s}.fl-wf i:nth-child(3n){animation-delay:-.4s}.fl-wf i:nth-child(4n){animation-delay:-.1s}' +
'@keyframes fl-eq-b{0%,100%{height:25%}50%{height:100%}}'; '@keyframes fl-wf{0%,100%{transform:scaleY(.4)}50%{transform:scaleY(1)}}' +
'@keyframes fl-in{from{opacity:0}to{opacity:1}}';
document.head.appendChild(st); document.head.appendChild(st);
} }
function _showIndicator(host) { function _showIndicator(host) {
@@ -772,7 +773,7 @@ function createFolderSurface(cfg) {
_ensurePreviewStyle(); _ensurePreviewStyle();
var ind = document.createElement('div'); var ind = document.createElement('div');
ind.className = 'fl-preview-ind'; ind.className = 'fl-preview-ind';
ind.innerHTML = '<span class="fl-eq"><i></i><i></i><i></i><i></i></span>'; ind.innerHTML = '<span class="fl-wf"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>';
host.appendChild(ind); host.appendChild(ind);
_previewIndHost = host; _previewIndHost = host;
} }
@@ -823,16 +824,19 @@ function createFolderSurface(cfg) {
} }
function _armHoverPreview(el, song, host) { function _armHoverPreview(el, song, host) {
el.addEventListener('mouseenter', function () { el.addEventListener('mouseenter', function () {
if (!_previewHover) return; if (!_previewHover || _dragState) return; // don't preview while dragging a song
clearTimeout(_previewTimer); clearTimeout(_previewTimer);
_previewTimer = setTimeout(function () { _previewTimer = setTimeout(function () {
if (_previewHover) _startPreview(song, host); if (_previewHover && !_dragState) _startPreview(song, host);
}, _HOVER_PREVIEW_DELAY_MS); }, _HOVER_PREVIEW_DELAY_MS);
}); });
el.addEventListener('mouseleave', function () { el.addEventListener('mouseleave', function () {
clearTimeout(_previewTimer); clearTimeout(_previewTimer);
_stopPreview(); _stopPreview();
}); });
// A click or drag-start cancels a pending preview AND stops one that's
// already playing, so it never runs mid-interaction.
el.addEventListener('mousedown', function () { clearTimeout(_previewTimer); _stopPreview(); });
} }
function _songCard(song, folderName) { function _songCard(song, folderName) {
@@ -1695,10 +1699,11 @@ function createFolderSurface(cfg) {
previewBtn.style.cssText = 'display:flex; align-items:center; gap:6px; padding:7px 12px; border:1px solid #374151; border-radius:10px; cursor:pointer; font-size:13px; white-space:nowrap; transition:color 0.1s, background 0.1s, border-color 0.1s;'; previewBtn.style.cssText = 'display:flex; align-items:center; gap:6px; padding:7px 12px; border:1px solid #374151; border-radius:10px; cursor:pointer; font-size:13px; white-space:nowrap; transition:color 0.1s, background 0.1s, border-color 0.1s;';
previewBtn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path d="M6 4l10 6-10 6V4z"/></svg>'; previewBtn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path d="M6 4l10 6-10 6V4z"/></svg>';
function _applyPreviewBtn() { function _applyPreviewBtn() {
previewBtn.style.background = _previewHover ? '#1d4ed8' : '#1f2937'; previewBtn.style.background = _previewHover ? '#1d4ed8' : '#171a22';
previewBtn.style.color = _previewHover ? '#ffffff' : '#9ca3af'; previewBtn.style.color = _previewHover ? '#ffffff' : '#565f6d';
previewBtn.style.borderColor = _previewHover ? '#3b82f6' : '#374151'; previewBtn.style.borderColor = _previewHover ? '#3b82f6' : '#2a303b';
previewBtn.title = 'Preview on hover: ' + (_previewHover ? 'on' : 'off'); previewBtn.style.opacity = _previewHover ? '1' : '0.5';
previewBtn.title = 'Preview song on hover: ' + (_previewHover ? 'on' : 'off');
} }
_applyPreviewBtn(); _applyPreviewBtn();
previewBtn.addEventListener('click', function () { previewBtn.addEventListener('click', function () {