mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
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.
This commit is contained in:
parent
b15cddc1d5
commit
452fbfa607
12
CHANGELOG.md
12
CHANGELOG.md
@ -82,13 +82,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
renderer is selected. The whole group also greys out while the Venue scene
|
||||
override is active, since none of the three controls reach a mounted style
|
||||
in that mode.
|
||||
- **Folder Library — preview song on hover.** The Folders library view gains a
|
||||
toolbar toggle (on by default): hovering a song ~0.8s previews its audio in
|
||||
place, with a waveform indicator over the artwork, and stops on leave. Uses a
|
||||
dedicated `<audio>` element (never the main player) and delegates preview
|
||||
resolution/serving to the `song_preview` plugin (manifest `preview:` key,
|
||||
backfill, Range) rather than resolving pack audio itself. The preference
|
||||
persists per surface in `localStorage`.
|
||||
|
||||
### Changed
|
||||
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
|
||||
@ -144,6 +137,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
|
||||
|
||||
@ -153,15 +153,14 @@ Each song object (built by `_meta()`):
|
||||
"added": 1748132400.0,
|
||||
"arrangements": ["Lead", "Rhythm", "Bass"],
|
||||
"stems": ["Drums", "Bass", "Vocals"],
|
||||
"lyrics": true,
|
||||
"has_preview": true
|
||||
"lyrics": true
|
||||
}
|
||||
```
|
||||
|
||||
- `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.
|
||||
- `has_preview` is set from the pack's manifest `preview:` key (via core `load_manifest`) — `true` iff `song_preview` will actually serve a preview. The frontend previews **only** `has_preview` songs, so hover never requests (and 404-logs) a preview-less pack. Hover-preview audio itself is **not** resolved here — it comes from the `song_preview` endpoint (see Preview on Hover).
|
||||
- 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
|
||||
|
||||
@ -333,17 +332,16 @@ Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTM
|
||||
|
||||
## 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).
|
||||
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:
|
||||
|
||||
- **Audio** — a dedicated `_previewAudio` `<audio>` element (never the main player's), played **from 0**. `_startPreview` runs **only when `song.has_preview`** (a backend flag from the manifest `preview:` key), so a preview-less pack is never requested — that's what keeps hover from logging 404s (a HEAD/`<audio>` request to `song_preview`'s 404 shows in the console even via `fetch`, because a plugin wraps `window.fetch`). The URL (`_previewUrl`) points at the **`song_preview` plugin** — `/api/plugins/song_preview/audio?file=<filename>` — the same endpoint the grid/list previews use. `song_preview` serves the clip **strictly** from the manifest `preview:` key (short baked clip, Range); there is **no stem fallback by design** (a full-length stem isn't a preview), so packs without a baked preview — hand-authored, most tutorials — don't preview until `song_preview`'s backfill generates one. Don't seek by `song.duration` — the clip is short, 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.
|
||||
- **`_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, the warm metadata cache, and **preview-on-hover** (see above).
|
||||
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:
|
||||
|
||||
|
||||
@ -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 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)
|
||||
- **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
|
||||
@ -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 song on hover | On by default; click the toolbar button to toggle. When on, hovering a song ~0.8s previews its audio |
|
||||
| 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 |
|
||||
|
||||
@ -12,14 +12,6 @@ from fastapi.responses import JSONResponse
|
||||
import shutil
|
||||
import re
|
||||
|
||||
# Core manifest reader — used only to tell the frontend which packs actually
|
||||
# carry a baked preview (manifest `preview:` key), so hover never requests a
|
||||
# preview-less pack and logs a 404. Guarded so the plugin still loads without it.
|
||||
try:
|
||||
from sloppak import load_manifest as _load_manifest
|
||||
except Exception:
|
||||
_load_manifest = None
|
||||
|
||||
|
||||
# ── Pure, testable helpers ─────────────────────────────────────────────────
|
||||
|
||||
@ -144,13 +136,7 @@ def setup(app, context):
|
||||
|
||||
# Cache miss — run the expensive extract.
|
||||
m = {"title": None, "artist": None, "album": None, "duration": None,
|
||||
"year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False,
|
||||
"has_preview": False}
|
||||
if _load_manifest is not None:
|
||||
try:
|
||||
m["has_preview"] = bool((_load_manifest(p) or {}).get("preview"))
|
||||
except Exception:
|
||||
pass
|
||||
"year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False}
|
||||
try:
|
||||
raw = context["extract_meta"](p)
|
||||
if raw:
|
||||
|
||||
@ -50,10 +50,6 @@ 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') !== '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
|
||||
|
||||
// ── Core arrangement order (pinned to top of filter panel) ──────────
|
||||
const _CORE_ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo'];
|
||||
@ -735,119 +731,15 @@ function createFolderSurface(cfg) {
|
||||
function _prompt(msg, def) { return _showModal(msg, true, def || ''); }
|
||||
|
||||
// ── Song card (grid view) ───────────────────────────────────────────
|
||||
// ── Preview on hover (opt-in; toggled from the toolbar) ─────────────
|
||||
// Streams the song's own audio into a dedicated <audio> element after the
|
||||
// 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 = 800; // dwell before preview — long enough that a click/drag doesn't trigger it
|
||||
var _previewIndHost = null; // art element currently showing the indicator
|
||||
|
||||
// Delegate to the song_preview plugin (the canonical preview subsystem): it
|
||||
// resolves the clip from the pack's manifest `preview:` key (falling back to
|
||||
// the default stem), serves it with Range support, and backfills a missing
|
||||
// preview.ogg. If song_preview isn't installed the endpoint 404s and the
|
||||
// audio's onerror clears the indicator — preview no-ops, nothing breaks.
|
||||
function _previewUrl(song) {
|
||||
if (!song || !song.filename) return null;
|
||||
return '/api/plugins/song_preview/audio?file=' + encodeURIComponent(song.filename);
|
||||
}
|
||||
|
||||
function _ensurePreviewStyle() {
|
||||
if (document.getElementById('fl-preview-style')) return;
|
||||
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;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) {
|
||||
_clearIndicator();
|
||||
if (!host) return;
|
||||
_ensurePreviewStyle();
|
||||
var ind = document.createElement('div');
|
||||
ind.className = 'fl-preview-ind';
|
||||
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;
|
||||
}
|
||||
function _markIndicatorPlaying() {
|
||||
var ind = _previewIndHost && _previewIndHost.querySelector('.fl-preview-ind');
|
||||
if (ind) ind.classList.add('playing');
|
||||
}
|
||||
function _clearIndicator() {
|
||||
if (!_previewIndHost) return;
|
||||
var ind = _previewIndHost.querySelector('.fl-preview-ind');
|
||||
if (ind) ind.remove();
|
||||
_previewIndHost = null;
|
||||
}
|
||||
|
||||
function _previewEl() {
|
||||
if (_previewAudio) return _previewAudio;
|
||||
var a = document.createElement('audio');
|
||||
a.preload = 'none';
|
||||
a.volume = 0.7;
|
||||
_previewAudio = a;
|
||||
return a;
|
||||
}
|
||||
function _stopPreview() {
|
||||
_previewSeq++; // any pending load/error/playing handler is now stale
|
||||
_clearIndicator();
|
||||
if (_previewAudio) {
|
||||
_previewAudio.onerror = _previewAudio.onloadedmetadata = _previewAudio.onplaying = null;
|
||||
try { _previewAudio.pause(); } catch (_) {}
|
||||
_previewAudio.removeAttribute('src');
|
||||
try { _previewAudio.load(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
function _startPreview(song, host) {
|
||||
// Only packs the backend flagged with a baked preview are requested at
|
||||
// all — so hover never hits song_preview's 404 for a preview-less pack
|
||||
// (which would log a console error). Play from 0; the clip is short.
|
||||
if (!song || !song.has_preview) return;
|
||||
var url = _previewUrl(song);
|
||||
if (!url) return;
|
||||
var a = _previewEl();
|
||||
var seq = ++_previewSeq;
|
||||
_showIndicator(host);
|
||||
a.onerror = function () { if (seq === _previewSeq) _clearIndicator(); };
|
||||
a.onloadedmetadata = function () { if (seq === _previewSeq) a.play().catch(function () {}); };
|
||||
a.onplaying = function () { if (seq === _previewSeq) _markIndicatorPlaying(); };
|
||||
a.src = url;
|
||||
a.load();
|
||||
}
|
||||
function _armHoverPreview(el, song, host) {
|
||||
el.addEventListener('mouseenter', function () {
|
||||
if (!_previewHover || _dragState) return; // don't preview while dragging a song
|
||||
clearTimeout(_previewTimer);
|
||||
_previewTimer = setTimeout(function () {
|
||||
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) {
|
||||
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';
|
||||
@ -906,7 +798,6 @@ function createFolderSurface(cfg) {
|
||||
if (typeof window.playSong === 'function') window.playSong(song.filename);
|
||||
});
|
||||
_makeDraggable(card, song, folderName);
|
||||
_armHoverPreview(card, song, artWrap);
|
||||
return card;
|
||||
}
|
||||
|
||||
@ -914,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';
|
||||
@ -977,7 +869,6 @@ function createFolderSurface(cfg) {
|
||||
if (typeof window.playSong === 'function') window.playSong(song.filename);
|
||||
});
|
||||
_makeDraggable(row, song, folderName);
|
||||
_armHoverPreview(row, song, thumb);
|
||||
return row;
|
||||
}
|
||||
|
||||
@ -1695,30 +1586,10 @@ function createFolderSurface(cfg) {
|
||||
);
|
||||
colBtn.addEventListener('click', _collapseAll);
|
||||
|
||||
// Preview-on-hover toggle (persisted per surface via cfg.storePrefix)
|
||||
var previewBtn = document.createElement('button');
|
||||
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' : '#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 () {
|
||||
_previewHover = !_previewHover;
|
||||
_store('previewHover', _previewHover ? 'true' : 'false');
|
||||
if (!_previewHover) { clearTimeout(_previewTimer); _stopPreview(); }
|
||||
_applyPreviewBtn();
|
||||
});
|
||||
|
||||
ctrl.appendChild(viewGroup);
|
||||
ctrl.appendChild(newBtn);
|
||||
ctrl.appendChild(expBtn);
|
||||
ctrl.appendChild(colBtn);
|
||||
ctrl.appendChild(previewBtn);
|
||||
|
||||
_toolbarDone = true;
|
||||
}
|
||||
@ -1735,7 +1606,6 @@ function createFolderSurface(cfg) {
|
||||
// ── Unload (lib surface) ────────────────────────────────────────────
|
||||
function _unload() {
|
||||
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
|
||||
_stopPreview(); // silence any in-progress hover preview
|
||||
if (!cfg.searchInputId) return;
|
||||
var el = _el(cfg.searchInputId);
|
||||
if (el) el.style.maxWidth = '';
|
||||
@ -1839,8 +1709,16 @@ function createFolderSurface(cfg) {
|
||||
init: _init,
|
||||
onScreenChanged: _onScreenChanged,
|
||||
render: _render,
|
||||
// Pure helpers, exposed for tests (no DOM needed).
|
||||
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER, previewUrl: _previewUrl },
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
// Hover-preview audio URL (folder_library preview-on-hover).
|
||||
//
|
||||
// The preview delegates to the canonical `song_preview` plugin instead of
|
||||
// resolving pack audio itself: `song_preview` reads the pack's manifest
|
||||
// (`preview:` key -> default stem) and serves the clip with Range support.
|
||||
// So `_previewUrl` just has to build that endpoint's URL from the song's
|
||||
// filename — with the filename URL-encoded as a query value (a '/', '#', '&',
|
||||
// or space in a folder/song name must not break the query). Returns null when
|
||||
// there's nothing to build from.
|
||||
|
||||
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');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
addEventListener() {},
|
||||
getElementById() { return null; },
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||
},
|
||||
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,
|
||||
encodeURIComponent, // the plugin encodes the filename query value with this
|
||||
};
|
||||
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 { previewUrl } = load();
|
||||
|
||||
test('null when there is no filename to build from', () => {
|
||||
assert.equal(previewUrl(null), null);
|
||||
assert.equal(previewUrl({}), null);
|
||||
assert.equal(previewUrl({ filename: '' }), null);
|
||||
});
|
||||
|
||||
test('delegates to the song_preview endpoint with the filename as a query value', () => {
|
||||
const url = previewUrl({ filename: 'Test/song.sloppak' });
|
||||
assert.equal(url, '/api/plugins/song_preview/audio?file=Test%2Fsong.sloppak');
|
||||
});
|
||||
|
||||
test('encodes the whole filename (slashes, #, &, spaces) as one query value', () => {
|
||||
const url = previewUrl({ filename: 'AC#DC/Back & Forth.sloppak' });
|
||||
// As a query value, '/' is encoded too — song_preview decodes it back to the path.
|
||||
assert.equal(url, '/api/plugins/song_preview/audio?file=AC%23DC%2FBack%20%26%20Forth.sloppak');
|
||||
});
|
||||
|
||||
test('does not read a per-pack audio member (resolution is song_preview\'s job)', () => {
|
||||
// Even if a caller passes a stale audio_member, it must be ignored — the
|
||||
// folder plugin no longer resolves pack audio itself.
|
||||
const url = previewUrl({ filename: 'CH/Song.sloppak', audio_member: 'stems/guitar.ogg' });
|
||||
assert.equal(url, '/api/plugins/song_preview/audio?file=CH%2FSong.sloppak');
|
||||
});
|
||||
116
plugins/folder_library/tests/song_preview_markup.test.js
Normal file
116
plugins/folder_library/tests/song_preview_markup.test.js
Normal file
@ -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'));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user