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
+16 -3
View File
@@ -153,13 +153,15 @@ Each song object (built by `_meta()`):
"added": 1748132400.0,
"arrangements": ["Lead", "Rhythm", "Bass"],
"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()`.
- `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.
- `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
@@ -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)
- **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
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:
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
- **Bulk move** — multi-select songs and move them all at once.
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
- **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
- **Album art** — pulls art automatically for every song in both views
- **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
- **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
@@ -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 list view | Click the list icon in the toolbar |
| 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 |
| Toggle sort direction | Click the arrow button next to the sort dropdown |
| 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 _toolbarDone = false;
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 _previewAudio = null; // dedicated element — never touches the main player's <audio>
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
// leave. Shows an equalizer "now playing" indicator over the art. Never
// 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
// The backend resolves the correct in-pack audio member (song.audio_member),
@@ -757,13 +757,14 @@ function createFolderSurface(cfg) {
var st = document.createElement('style');
st.id = 'fl-preview-style';
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-eq{display:flex;align-items:flex-end;gap:2px;height:45%;max-height:22px;opacity:.55;}' +
'.fl-preview-ind.playing .fl-eq{opacity:1;}' +
'.fl-eq i{display:block;width:3px;height:25%;background:#60a5fa;border-radius:1px;}' +
'.fl-preview-ind.playing .fl-eq i{animation:fl-eq-b .8s ease-in-out infinite;}' +
'.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;}' +
'@keyframes fl-eq-b{0%,100%{height:25%}50%{height:100%}}';
'.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-wf{display:flex;align-items:center;gap:2px;height:55%;max-height:22px;opacity:.55;transition:opacity .2s ease;}' +
'.fl-preview-ind.playing .fl-wf{opacity:1;}' +
'.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-wf i{animation-name:fl-wf;animation-duration:1s;animation-timing-function:ease-in-out;animation-iteration-count:infinite;will-change:transform;}' +
'.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-wf{0%,100%{transform:scaleY(.4)}50%{transform:scaleY(1)}}' +
'@keyframes fl-in{from{opacity:0}to{opacity:1}}';
document.head.appendChild(st);
}
function _showIndicator(host) {
@@ -772,7 +773,7 @@ function createFolderSurface(cfg) {
_ensurePreviewStyle();
var ind = document.createElement('div');
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);
_previewIndHost = host;
}
@@ -823,16 +824,19 @@ function createFolderSurface(cfg) {
}
function _armHoverPreview(el, song, host) {
el.addEventListener('mouseenter', function () {
if (!_previewHover) return;
if (!_previewHover || _dragState) return; // don't preview while dragging a song
clearTimeout(_previewTimer);
_previewTimer = setTimeout(function () {
if (_previewHover) _startPreview(song, host);
if (_previewHover && !_dragState) _startPreview(song, host);
}, _HOVER_PREVIEW_DELAY_MS);
});
el.addEventListener('mouseleave', function () {
clearTimeout(_previewTimer);
_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) {
@@ -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.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path d="M6 4l10 6-10 6V4z"/></svg>';
function _applyPreviewBtn() {
previewBtn.style.background = _previewHover ? '#1d4ed8' : '#1f2937';
previewBtn.style.color = _previewHover ? '#ffffff' : '#9ca3af';
previewBtn.style.borderColor = _previewHover ? '#3b82f6' : '#374151';
previewBtn.title = 'Preview on hover: ' + (_previewHover ? 'on' : 'off');
previewBtn.style.background = _previewHover ? '#1d4ed8' : '#171a22';
previewBtn.style.color = _previewHover ? '#ffffff' : '#565f6d';
previewBtn.style.borderColor = _previewHover ? '#3b82f6' : '#2a303b';
previewBtn.style.opacity = _previewHover ? '1' : '0.5';
previewBtn.title = 'Preview song on hover: ' + (_previewHover ? 'on' : 'off');
}
_applyPreviewBtn();
previewBtn.addEventListener('click', function () {