diff --git a/CHANGELOG.md b/CHANGELOG.md index 6294fd6c..e765bcbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/plugins/folder_library/CLAUDE.md b/plugins/folder_library/CLAUDE.md index a44ea027..dc69ab55 100644 --- a/plugins/folder_library/CLAUDE.md +++ b/plugins/folder_library/CLAUDE.md @@ -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. diff --git a/plugins/folder_library/README.md b/plugins/folder_library/README.md index 2d102f8a..b9c46050 100644 --- a/plugins/folder_library/README.md +++ b/plugins/folder_library/README.md @@ -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 diff --git a/plugins/folder_library/plugin.json b/plugins/folder_library/plugin.json index 02b8a931..f835fafc 100644 --- a/plugins/folder_library/plugin.json +++ b/plugins/folder_library/plugin.json @@ -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", diff --git a/plugins/folder_library/screen.js b/plugins/folder_library/screen.js index 8ddec845..24b8ddf7 100644 --- a/plugins/folder_library/screen.js +++ b/plugins/folder_library/screen.js @@ -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, + }, }; } diff --git a/plugins/folder_library/tests/song_preview_markup.test.js b/plugins/folder_library/tests/song_preview_markup.test.js new file mode 100644 index 00000000..c56642ce --- /dev/null +++ b/plugins/folder_library/tests/song_preview_markup.test.js @@ -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')); +});