Compare commits

...
Author SHA1 Message Date
Kyle 9157e2ecf7 Remove hover-preview; delegate to song_preview plugin
Folder Library now uses the standard data-fn/data-v3-play markup so the song_preview plugin handles hover-previews, the same way it does for grid and list views. Removes ~200 lines of preview-specific code: dedicated audio element, indicator rendering, toolbar toggle, and backend manifest checking. The preview experience is now unified across all library surfaces.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle 51fe217e15 Remove outdated screen.js caching note
Remove the developer note about manually bumping plugin.json version when changing screen.js

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle 9e5e57f048 Use manifest preview flag to avoid hover 404s
Expose a has_preview boolean from pack manifests and use it to gate hover previews. backend: plugins/folder_library/routes.py now (optionally) reads sloppak.load_manifest to set m['has_preview'] (guarded so plugin still loads without sloppak). frontend: plugins/folder_library/screen.js skips preview requests when song.has_preview is false and removes the HEAD-probe + previewMissing cache. docs: plugins/folder_library/CLAUDE.md updated to document has_preview and the preview behavior. This prevents unnecessary HEAD/audio 404s and console noise.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle ecf559cd6d Refactor folder_library preview to delegate to song_preview
Remove audio member resolution from folder_library and delegate hover-preview audio to the canonical song_preview plugin endpoint. This separates concerns: folder_library now only builds the preview URL with the filename, while song_preview handles manifest resolution (preview: key, stem fallback) and Range support.

Changes:
- Remove _audio_member() resolver and audio_member field from pack metadata
- Update _previewUrl() to point to /api/plugins/song_preview/audio?file=<filename>
- Add _previewMissing cache to avoid re-requesting packs with no preview
- Add HEAD probe to check preview availability before playing
- Add test coverage for _previewUrl with special character encoding

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:14 -04:00
Kyle ad9ad229c9 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>
2026-07-23 00:51:14 -04:00
Kyle 9f8e1e6f61 Resolve in-pack audio on backend
Move audio member selection logic from frontend to backend. The frontend was probing multiple candidate audio files with error/retry logic, often resulting in 404s. Now the backend resolves the best available audio file (preview.ogg, stems/full.ogg, or any stem) during metadata extraction and includes it in song metadata as `audio_member`. The frontend makes a single, guaranteed request instead of multiple probes. Simplifies the preview flow and improves reliability.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:13 -04:00
Kyle 28bfa6ae0b Add preview-on-hover to Folder Library
Implement optional audio preview when hovering over songs in the Folders view. Features include:
- Toolbar toggle button (off by default) to enable/disable hover preview
- ~0.5s dwell delay to avoid accidental audio playback while scrolling
- Dedicated <audio> element (never touches main player)
- Equalizer indicator animation overlay on song art while playing
- Per-surface localStorage persistence
- Automatic fallback through preview.ogg → stems/full.ogg → stems/audio.mp3

Bump Folder Library from 1.8.0 to 1.9.0. Remove 'auto play on hover' from roadmap as it's now implemented.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:13 -04:00
6 changed files with 151 additions and 8 deletions
+5
View File
@@ -183,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
at `default: off`, drops the key); the fallback and the aliases are removed once
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
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
+11 -2
View File
@@ -160,6 +160,7 @@ Each song object (built by `_meta()`):
- `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.
- Hover-preview isn't resolved here at all — the cards just carry `data-fn`/`data-v3-play` markup and the `song_preview` plugin handles it (see Preview on Hover).
### extract_meta returns arrangements/stems as objects, not strings
@@ -329,13 +330,21 @@ 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
The Folder Library does **not** implement hover-preview itself — the `song_preview` plugin does, for the whole app. Its hover loop finds song elements by the selector `#v3-songs [data-fn]` (with a `[data-v3-play]` playable surface) and its `MutationObserver` watches the `#v3-songs` subtree — and the folder view (`#lib-folder-tree`) renders **inside** `#v3-songs`. So all folder_library has to do is give each card/row the standard markup:
- **`_songCard`** — `card.dataset.fn = song.filename` (raw filename) + `data-v3-play` on the art wrap (the surface `song_preview` overlays its indicator on).
- **`_songRow`** — `row.dataset.fn = song.filename` + `data-v3-play` on the thumb.
`song_preview` then previews folder view exactly like the grid/list — same audio (its `/audio` endpoint), same availability/404 handling, same indicator — with **zero preview code here**. If you change the card/row structure, keep `data-fn` (raw, not URL-encoded) and a `[data-v3-play]` descendant, or `song_preview` will stop recognising the cards.
## 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, and the warm metadata cache. Hover-preview is provided by the `song_preview` plugin via the `data-fn` markup above (not implemented here).
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.
+4 -1
View File
@@ -30,6 +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 song on hover** — hover a song to hear a quick preview of its audio (powered by the Song Preview plugin, the same way the grid and list views work)
- **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
@@ -54,6 +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 song on hover | Hover a song — the Song Preview plugin plays a quick clip (same as the grid/list views) |
| 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 |
@@ -80,7 +82,8 @@ Folder Library started life as a standalone plugin with its own version line, bu
## Roadmap
- [ ] Auto play song on hover (with an on/off toggle)
- [ ] Compatibility with core settings — respect Accessibility → Interface size
- [ ] Core parity — Metadata, Unmatched, and Upload should work in the folder view too
- [ ] Bulk move — select multiple songs and move them at once
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "folder_library",
"name": "Folder Library",
"version": "1.8.0",
"version": "1.9.0",
"bundled": true,
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
"screen": "screen.html",
+14 -4
View File
@@ -735,10 +735,11 @@ function createFolderSurface(cfg) {
var card = document.createElement('div');
card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105';
card.style.background = '#1a1d2e';
card.dataset.filename = song.filename;
card.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds cards by this
var artWrap = document.createElement('div');
artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;';
artWrap.setAttribute('data-v3-play', ''); // the playable surface song_preview overlays its indicator on
var img = document.createElement('img');
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;';
img.alt = ''; img.loading = 'lazy';
@@ -804,10 +805,11 @@ function createFolderSurface(cfg) {
function _songRow(song, folderName) {
var row = document.createElement('div');
row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100';
row.dataset.filename = song.filename;
row.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds rows by this
var thumb = document.createElement('div');
thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;';
thumb.setAttribute('data-v3-play', ''); // marks the row previewable for song_preview
var tImg = document.createElement('img');
tImg.loading = 'lazy';
tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
@@ -1707,8 +1709,16 @@ function createFolderSurface(cfg) {
init: _init,
onScreenChanged: _onScreenChanged,
render: _render,
// Pure window arithmetic, exposed for tests (no DOM needed).
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
// Helpers exposed for tests. visibleWindow is pure; songCard/songRow
// need a DOM (the tests supply a minimal element mock) and pin the
// song_preview integration markup (data-fn + a data-v3-play surface).
__test: {
visibleWindow: _visibleWindow,
VIRTUAL_MIN: VIRTUAL_MIN,
VIRTUAL_BUFFER: VIRTUAL_BUFFER,
songCard: _songCard,
songRow: _songRow,
},
};
}
@@ -0,0 +1,116 @@
// song_preview integration markup (feedBack — Folders view hover preview).
//
// The Folder Library does NOT implement hover-preview itself. It relies on the
// separate `song_preview` plugin, exactly like the grid and list views. That
// plugin's host adapter finds previewable elements with the selector
// `#v3-songs [data-fn]` and requires each to contain a `[data-v3-play]`
// descendant (the surface it overlays its indicator on), reading the raw
// filename from `data-fn`.
//
// So the ENTIRE contract Folder Library owns is: every song card and row it
// renders must carry `data-fn` (raw filename) and expose a `[data-v3-play]`
// surface. If a refactor drops either, folder cards silently stop previewing
// while grid/list keep working — a regression that's invisible without a live
// song_preview install. These tests pin the markup so that can't happen.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
// A minimal DOM element mock — just enough for _songCard / _songRow to run.
// No jsdom in this repo (see virtual_list.test.js); the element tracks the few
// things the contract cares about: dataset, attributes, and a child tree that
// querySelector('[data-v3-play]') can walk.
function makeEl(tag) {
const attrs = {};
const el = {
tagName: String(tag || '').toUpperCase(),
style: {}, // supports .cssText and arbitrary props
dataset: {},
className: '',
children: [],
parentNode: null,
addEventListener() {},
removeEventListener() {},
setAttribute(k, v) { attrs[k] = String(v); },
getAttribute(k) { return k in attrs ? attrs[k] : null; },
hasAttribute(k) { return k in attrs; },
appendChild(child) { el.children.push(child); if (child) child.parentNode = el; return child; },
classList: { add() {}, remove() {}, contains() { return false; }, toggle() {} },
remove() {},
// Only the '[data-v3-play]'-style attribute selector is needed.
querySelector(sel) {
const attr = sel.replace(/^\[|\]$/g, '');
const stack = el.children.slice();
while (stack.length) {
const n = stack.shift();
if (n && n.hasAttribute && n.hasAttribute(attr)) return n;
if (n && n.children) stack.push(...n.children);
}
return null;
},
};
return el;
}
function load() {
const window = {
console,
document: {
readyState: 'complete',
addEventListener() {},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
createElement(tag) { return makeEl(tag); },
},
addEventListener() {},
localStorage: { getItem() { return null; }, setItem() {} },
performance: { now: () => 0 },
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame() { return 0; },
cancelAnimationFrame() {},
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
innerHeight: 800,
};
window.window = window;
window.globalThis = window;
const ctx = vm.createContext(window);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
return window.folderLibrary.__test;
}
const { songCard, songRow } = load();
// A raw filename with a subfolder + spaces — the kind of value song_preview
// URL-encodes downstream, so it must reach data-fn verbatim, not pre-encoded.
const FILENAME = 'Some Artist/A Song.sloppak';
const SONG = { filename: FILENAME, title: 'A Song', artist: 'Some Artist' };
test('song_preview helpers are exposed for the markup contract', () => {
assert.equal(typeof songCard, 'function');
assert.equal(typeof songRow, 'function');
});
test('grid card carries data-fn (raw) and a data-v3-play surface', () => {
const card = songCard(SONG, 'Unsorted');
assert.equal(card.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
assert.ok(card.querySelector('[data-v3-play]'), 'card must contain a [data-v3-play] surface');
});
test('list row carries data-fn (raw) and a data-v3-play surface', () => {
const row = songRow(SONG, 'Unsorted');
assert.equal(row.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
assert.ok(row.querySelector('[data-v3-play]'), 'row must contain a [data-v3-play] surface');
});
test('card renders without depending on any optional song metadata', () => {
// song_preview only needs filename; the card must build from a bare song
// (no duration/arrangements/stems/lyrics/tuning/year) without throwing.
assert.doesNotThrow(() => songCard({ filename: FILENAME }, 'Unsorted'));
assert.doesNotThrow(() => songRow({ filename: FILENAME }, 'Unsorted'));
});