/* Folder Browser — screen.js
* Plain JS, global scope, IIFE. Follows feedBack plugin conventions.
*
* ONE shared implementation (`createFolderSurface`) parameterised by a
* "surface config", consumed by two thin adapters:
* • NAV adapter — the standalone "Folders" nav-screen in the classic (v2)
* UI. Owns its own toolbar + search (#fb-*), client-side filter panel and
* local sort, renders into #fb-tree.
* • LIB adapter — the folder VIEW embedded in the v3 Songs page. Uses host
* chrome (host search #v3-search/#lib-filter, host filter params, host
* sort), renders into #lib-folder-tree, injects a toolbar into
* #lib-folder-controls, and exposes window.folderLibrary = {load, unload}.
*
* Each surface is an independent factory instance with its own closure state,
* so the two never share mutable state even when both run on the same page
* (they do in classic v2, where #lib-folder-tree also exists).
*/
(function () {
'use strict';
const API = '/api/plugins/folder_library';
// ════════════════════════════════════════════════════════════════════════
// Shared surface factory
// ════════════════════════════════════════════════════════════════════════
function createFolderSurface(cfg) {
// ── Safe localStorage helpers (cfg.storePrefix keeps surfaces separate) ─
function _store(key, val) {
try {
if (val === undefined) return localStorage.getItem(cfg.storePrefix + key);
localStorage.setItem(cfg.storePrefix + key, val);
} catch (_) { return null; }
}
function _storeJSON(key, val) {
try {
if (val === undefined) return JSON.parse(localStorage.getItem(cfg.storePrefix + key) || 'null');
localStorage.setItem(cfg.storePrefix + key, JSON.stringify(val));
} catch (_) { return null; }
}
// ── State ───────────────────────────────────────────────────────────
let _tree = null;
let _loaded = false;
let _lastFilterParams = null; // params string used for the last /tree fetch
let _openFolders = new Set(_storeJSON('open') || []);
let _unsortedOpen = _store(cfg.unsortedKey) !== 'false';
let _view = _store('view') || 'list'; // 'list' | 'grid'
let _sort = _store('sort') || 'default';
let _sortDir = _store('sortDir') || 'asc';
let _toolbarDone = false;
let _hoveredFolder = null; // { wrap, hdr, btnGroup } — only innermost folder is active
// ── Core arrangement order (pinned to top of filter panel) ──────────
const _CORE_ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo'];
// ── Client-side filter state (nav surface only) ─────────────────────
var _filtersRaw = _storeJSON('filters') || {};
function _normFilterGroup(g) {
var out = {};
for (var k in (g || {})) {
var v = g[k];
out[k] = v === 'require' ? 'on' : v === 'any' ? 'off' : v;
}
return out;
}
let _filters = {
arrangements: _normFilterGroup(_filtersRaw.arrangements),
stems: _normFilterGroup(_filtersRaw.stems),
lyrics: (_filtersRaw.lyrics === 'require' || _filtersRaw.lyrics === 'on') ? 'on'
: (_filtersRaw.lyrics === 'exclude') ? 'exclude' : 'off',
tunings: _filtersRaw.tunings || [],
};
// ── DOM helpers ─────────────────────────────────────────────────────
function _el(id) { return document.getElementById(id); }
function _treeEl() { return document.getElementById(cfg.treeId); }
// ── Force screen to have height (nav screen has no height set) ──────
function _fixHeight() {
const el = _el(cfg.screenId);
const nav = document.querySelector('nav');
const navH = nav ? nav.offsetHeight : 64;
if (el) el.style.minHeight = (window.innerHeight - navH) + 'px';
}
// ── Close the nav plugin dropdown (sits at z-50 and blocks clicks) ──
function _closeDropdown() {
var dd = _el('plugin-dropdown');
if (dd) dd.classList.add('hidden');
}
// ── Status (nav status bar only) ────────────────────────────────────
// Gated by cfg.ownsStatus so the library surface never mutates the nav
// screen's shared #fb-status element when both instances live on one page.
function _status(msg, isErr) {
if (!cfg.ownsStatus) return;
const el = _el('fb-status');
if (!el) return;
el.textContent = msg || '';
el.className = 'text-xs ml-1 ' + (isErr ? 'text-red-400' : 'text-gray-500');
}
// ── API helper ──────────────────────────────────────────────────────
async function _api(path, body) {
const opts = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {};
const res = await fetch(cfg.apiBase + path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
// ── Search value (read live from the surface's search input) ────────
function _query() {
var el = cfg.getSearchEl ? cfg.getSearchEl() : null;
return el ? el.value.trim() : '';
}
// ── Flat list of every song in the tree (root + all nested folders) ─
function _allSongs() {
if (!_tree) return [];
var result = _tree.root_songs.slice();
function _collectFolder(f) {
f.songs.forEach(function (s) { result.push(s); });
(f.children || []).forEach(_collectFolder);
}
_tree.folders.forEach(_collectFolder);
return result;
}
// ── Dynamic arrangement / stem discovery (filter panel) ─────────────
function _getArrangements() {
var counts = {};
_allSongs().forEach(function (s) {
(s.arrangements || []).forEach(function (a) { counts[a] = (counts[a] || 0) + 1; });
});
return Object.keys(counts).sort(function (a, b) { return (counts[b] - counts[a]) || a.localeCompare(b); });
}
function _getStems() {
var counts = {};
_allSongs().forEach(function (s) {
(s.stems || []).forEach(function (st) { counts[st] = (counts[st] || 0) + 1; });
});
return Object.keys(counts).sort(function (a, b) { return (counts[b] - counts[a]) || a.localeCompare(b); });
}
function _getAvailableFilters() {
var out = { arrangements: false, stems: false, lyrics: false, tuning: false };
_allSongs().forEach(function (s) {
if ((s.arrangements || []).length) out.arrangements = true;
if ((s.stems || []).length) out.stems = true;
if (s.lyrics) out.lyrics = true;
if (s.tuning) out.tuning = true;
});
return out;
}
function _getTunings() {
var counts = {};
_allSongs().forEach(function (s) {
var t = s.tuning ? String(s.tuning).trim() : '';
if (t) counts[t] = (counts[t] || 0) + 1;
});
return Object.keys(counts)
.sort(function (a, b) { return a.localeCompare(b); })
.map(function (t) { return { tuning: t, count: counts[t] }; });
}
// ── Fetch tree ──────────────────────────────────────────────────────
async function _load(force) {
// Lib surface owns a couple of host-chrome tweaks on entry.
if (cfg.searchInputId) {
var fe = _el(cfg.searchInputId);
if (fe) fe.style.maxWidth = '320px';
}
if (cfg.countId) {
var ce0 = _el(cfg.countId);
if (ce0) ce0.textContent = '';
}
var params = cfg.getFilterParams ? cfg.getFilterParams() : '';
if (!force && _loaded && _tree && params === _lastFilterParams) {
if (cfg.injectToolbar) _injectToolbar();
_render();
return;
}
_status('Loading…');
var treeEl = _treeEl();
if (!cfg.ownsStatus && treeEl) {
treeEl.innerHTML = '
Loading folders…
';
}
try {
var url = '/tree' + (params ? '?' + params : '');
var data = await _api(url);
if (data.error) {
if (cfg.ownsStatus) _status('⚠ ' + data.error, true);
else if (treeEl) { treeEl.innerHTML = ''; var _ed = document.createElement('div'); _ed.style.cssText = 'padding:48px;text-align:center;color:#ef4444;font-size:13px;'; _ed.textContent = '⚠ ' + data.error; treeEl.appendChild(_ed); }
return;
}
_tree = data;
_loaded = true;
_lastFilterParams = params;
_status('');
// Lib auto-expands top-level folders on first visit (empty open set).
if (cfg.autoExpandTop && _openFolders.size === 0 && data.folders.length) {
data.folders.forEach(function (f) { _openFolders.add(f.path); });
_storeJSON('open', [..._openFolders]);
}
if (cfg.injectToolbar) _injectToolbar();
_render();
// Rebuild filter panel if it's open so tuning list reflects new data.
if (cfg.ownsFilterPanel) {
var fp = _el('fb-filter-panel');
if (fp && fp.style.display !== 'none') _buildFilterPanel();
}
} catch (err) {
if (cfg.ownsStatus) _status('Load failed: ' + err.message, true);
else if (treeEl) { treeEl.innerHTML = ''; var _ed = document.createElement('div'); _ed.style.cssText = 'padding:48px;text-align:center;color:#ef4444;font-size:13px;'; _ed.textContent = '⚠ Failed to load: ' + err.message; treeEl.appendChild(_ed); }
}
}
// ── Filtered tree ───────────────────────────────────────────────────
// Search always applies. Nav additionally applies its client-side filter
// panel; lib additionally narrows by host artist/album (server already
// applied arrangement/stem/tuning filters via /tree params).
function _filtered() {
if (!_tree) return { folders: [], root_songs: [] };
var q = _query().toLowerCase();
var artist = cfg.getHostArtist ? cfg.getHostArtist() : '';
var album = cfg.getHostAlbum ? cfg.getHostAlbum() : '';
var hasClientFilters = cfg.ownsFilterPanel && _activeFilterCount() > 0;
if (!q && !artist && !album && !hasClientFilters) return _tree;
function _keep(s) {
if (artist && (s.artist || '') !== artist) return false;
if (album && (s.album || '') !== album) return false;
if (q && !(
(s.title || '').toLowerCase().includes(q) ||
(s.artist || '').toLowerCase().includes(q) ||
(s.album || '').toLowerCase().includes(q) ||
s.filename.toLowerCase().includes(q)
)) return false;
if (hasClientFilters && !_matchFilters(s)) return false;
return true;
}
function _filterFolder(f) {
var songs = f.songs.filter(_keep);
var children = (f.children || []).map(_filterFolder).filter(function (c) {
return c.songs.length || (c.children || []).length;
});
return { name: f.name, path: f.path, songs: songs, children: children };
}
var folders = _tree.folders.map(_filterFolder).filter(function (f) {
return f.songs.length || (f.children || []).length;
});
return { folders: folders, root_songs: _tree.root_songs.filter(_keep) };
}
// ── Client-side filter helpers (nav surface) ────────────────────────
function _saveFilters() {
_storeJSON('filters', _filters);
}
function _activeFilterCount() {
var n = 0;
var arrVals = _filters.arrangements || {};
for (var a in arrVals) { if (arrVals[a] === 'on' || arrVals[a] === 'exclude') n++; }
var stemVals = _filters.stems || {};
for (var s in stemVals) { if (stemVals[s] === 'on' || stemVals[s] === 'exclude') n++; }
if (_filters.lyrics === 'on' || _filters.lyrics === 'exclude') n++;
n += (_filters.tunings || []).length;
return n;
}
function _matchFilters(song) {
// Arrangements — include uses OR, exclude uses AND.
var arrF = _filters.arrangements || {};
var songArr = song.arrangements || [];
var onArr = Object.keys(arrF).filter(function (a) { return arrF[a] === 'on'; });
if (onArr.length && !onArr.some(function (a) { return songArr.indexOf(a) !== -1; })) return false;
for (var a in arrF) {
if (arrF[a] === 'exclude' && songArr.indexOf(a) !== -1) return false;
}
// Stems — same OR-include / AND-exclude logic.
var stemsF = _filters.stems || {};
var songStems = song.stems || [];
var onStems = Object.keys(stemsF).filter(function (s) { return stemsF[s] === 'on'; });
if (onStems.length && !onStems.some(function (s) { return songStems.indexOf(s) !== -1; })) return false;
for (var s in stemsF) {
if (stemsF[s] === 'exclude' && songStems.indexOf(s) !== -1) return false;
}
if (_filters.lyrics === 'on' && !song.lyrics) return false;
if (_filters.lyrics === 'exclude' && song.lyrics) return false;
var tunings = _filters.tunings || [];
if (tunings.length) {
var t = (song.tuning || '').trim();
if (!t || tunings.indexOf(t) === -1) return false;
}
return true;
}
// Split pill: left zone = include, right zone = exclude. state: 'off'|'on'|'exclude'
function _makeSplitPill(label, state, onChange) {
var pill = document.createElement('div');
pill.style.cssText = 'display:inline-flex; border-radius:20px; border:1px solid; overflow:hidden;';
var incBtn = document.createElement('button');
incBtn.style.cssText = 'padding:4px 10px; background:none; border:none; border-right:1px solid; font-size:12px; cursor:pointer; white-space:nowrap;';
incBtn.textContent = label;
var excBtn = document.createElement('button');
excBtn.style.cssText = 'padding:4px 8px; background:none; border:none; font-size:11px; cursor:pointer; line-height:1;';
excBtn.title = 'Exclude';
excBtn.textContent = '✕';
function _apply() {
if (state === 'on') {
pill.style.borderColor = '#2563eb';
incBtn.style.background = '#1d4ed8';
incBtn.style.color = '#fff';
incBtn.style.borderRightColor = '#3b82f6';
excBtn.style.background = '#1d4ed8';
excBtn.style.color = 'rgba(255,255,255,0.45)';
} else if (state === 'exclude') {
pill.style.borderColor = '#991b1b';
incBtn.style.background = 'transparent';
incBtn.style.color = '#fca5a5';
incBtn.style.borderRightColor = '#7f1d1d';
excBtn.style.background = 'transparent';
excBtn.style.color = '#ef4444';
} else {
pill.style.borderColor = '#374151';
incBtn.style.background = 'transparent';
incBtn.style.color = '#6b7280';
incBtn.style.borderRightColor = '#374151';
excBtn.style.background = 'transparent';
excBtn.style.color = '#4b5563';
}
}
_apply();
incBtn.addEventListener('click', function () {
state = (state === 'on') ? 'off' : 'on';
_apply(); onChange(state);
});
excBtn.addEventListener('click', function () {
state = (state === 'exclude') ? 'off' : 'exclude';
_apply(); onChange(state);
});
pill.appendChild(incBtn);
pill.appendChild(excBtn);
return pill;
}
// ── Song date info (year + date added) — hover reveal (nav) ─────────
function _buildSongDateInfo(song) {
var parts = [];
if (song.year != null && song.year !== '') parts.push(String(song.year));
if (song.added) {
var d = new Date(song.added * 1000);
parts.push(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }));
}
if (!parts.length) return null;
var el = document.createElement('div');
el.style.cssText = 'font-size:11px; font-weight:500; color:#cbd5e1; ' +
'max-height:0; opacity:0; overflow:hidden; margin-top:0; ' +
'transition:max-height 0.2s ease, opacity 0.15s, margin-top 0.15s;';
el.textContent = parts.join(' · ');
return el;
}
// ── Song metadata badges (visible on hover; click toggles a filter) ─
function _badge(text, active, type) {
var b = document.createElement('span');
var _typeColors = {
arrangement: { border: '#92400e', color: '#fcd34d' },
stem: { border: '#5b21b6', color: '#c4b5fd' },
lyrics: { border: '#9f1239', color: '#fda4af' },
tuning: { border: '#0f766e', color: '#5eead4' },
};
var tc = (!active && type) ? (_typeColors[type] || null) : null;
b.style.cssText = 'display:inline-block; padding:1px 6px; border-radius:3px; ' +
'font-size:10px; font-weight:500; white-space:nowrap; cursor:pointer; ' +
'border:1px solid ' + (active ? '#3b82f6' : (tc ? tc.border : '#334155')) + '; ' +
'background:' + (active ? '#1d4ed8' : 'transparent') + '; ' +
'color:' + (active ? '#fff' : (tc ? tc.color : '#cbd5e1')) + ';';
b.textContent = text;
return b;
}
function _buildSongBadges(song) {
var wrap = document.createElement('div');
wrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:3px; ' +
'max-height:0; opacity:0; overflow:hidden; margin-top:0; ' +
'transition:max-height 0.2s ease, opacity 0.15s, margin-top 0.15s;';
var any = false;
var _seenArr = {};
var _seenStem = {};
(song.arrangements || []).forEach(function (a) {
if (_seenArr[a]) return; _seenArr[a] = true;
var active = ((_filters.arrangements || {})[a] === 'on');
var b = _badge(a, active, 'arrangement');
b.addEventListener('click', function (e) {
e.stopPropagation();
if (!_filters.arrangements) _filters.arrangements = {};
_filters.arrangements[a] = active ? 'off' : 'on';
_saveFilters(); _updateFilterBadge(); _render();
});
wrap.appendChild(b); any = true;
});
(song.stems || []).forEach(function (s) {
if (_seenStem[s]) return; _seenStem[s] = true;
var active = ((_filters.stems || {})[s] === 'on');
var b = _badge(s, active, 'stem');
b.addEventListener('click', function (e) {
e.stopPropagation();
if (!_filters.stems) _filters.stems = {};
_filters.stems[s] = active ? 'off' : 'on';
_saveFilters(); _updateFilterBadge(); _render();
});
wrap.appendChild(b); any = true;
});
if (song.lyrics) {
var lyrActive = (_filters.lyrics === 'on');
var lb = _badge('♪ Lyrics', lyrActive, 'lyrics');
lb.addEventListener('click', function (e) {
e.stopPropagation();
_filters.lyrics = lyrActive ? 'off' : 'on';
_saveFilters(); _updateFilterBadge(); _render();
});
wrap.appendChild(lb); any = true;
}
if (song.tuning) {
var t = song.tuning.trim();
var tunActive = (_filters.tunings || []).indexOf(t) !== -1;
var tb = _badge(t, tunActive, 'tuning');
tb.addEventListener('click', function (e) {
e.stopPropagation();
if (!_filters.tunings) _filters.tunings = [];
var idx = _filters.tunings.indexOf(t);
if (idx !== -1) _filters.tunings.splice(idx, 1);
else _filters.tunings.push(t);
_saveFilters(); _updateFilterBadge(); _render();
});
wrap.appendChild(tb); any = true;
}
return any ? wrap : null;
}
function _revealBadges(el) {
el.style.maxHeight = '120px';
el.style.opacity = '1';
el.style.marginTop = '4px';
}
function _hideBadges(el) {
el.style.maxHeight = '0';
el.style.opacity = '0';
el.style.marginTop = '0';
}
function _updateFilterBadge() {
var badge = _el('fb-filter-badge');
if (!badge) return;
var n = _activeFilterCount();
badge.style.display = n ? 'block' : 'none';
badge.textContent = String(n);
}
// ── Filter panel sections (nav) ─────────────────────────────────────
function _makePillSection(sectionTitle, items, filterKey, extraItems) {
var section = document.createElement('div');
section.style.marginBottom = '20px';
var hdr = document.createElement('div');
hdr.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280; margin-bottom:8px;';
hdr.textContent = sectionTitle;
section.appendChild(hdr);
var pills = document.createElement('div');
pills.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;';
function _addPill(item) {
var state = ((_filters[filterKey] || {})[item]) || 'off';
pills.appendChild(_makeSplitPill(item, state, function (next) {
if (!_filters[filterKey]) _filters[filterKey] = {};
_filters[filterKey][item] = next;
_saveFilters(); _updateFilterBadge(); _render();
}));
}
items.forEach(_addPill);
if (extraItems && extraItems.length) {
var sep = document.createElement('div');
sep.style.cssText = 'width:100%; height:1px; background:#1f2937; margin:4px 0 2px;';
pills.appendChild(sep);
extraItems.forEach(_addPill);
}
section.appendChild(pills);
return section;
}
function _makeLyricsSection() {
var section = document.createElement('div');
section.style.marginBottom = '20px';
var hdr = document.createElement('div');
hdr.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280; margin-bottom:8px;';
hdr.textContent = 'LYRICS';
section.appendChild(hdr);
var state = _filters.lyrics || 'off';
section.appendChild(_makeSplitPill('Lyrics', state, function (next) {
_filters.lyrics = next;
_saveFilters(); _updateFilterBadge(); _render();
}));
return section;
}
function _makeTuningSection() {
var section = document.createElement('div');
section.style.marginBottom = '20px';
var tunings = _getTunings();
if (!tunings.length) return section;
var titleRow = document.createElement('div');
titleRow.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;';
var titleEl = document.createElement('div');
titleEl.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280;';
titleEl.textContent = 'TUNING';
var allLbl = document.createElement('span');
allLbl.style.cssText = 'font-size:11px; color:#6b7280;';
function _updateAllLbl() {
var n = (_filters.tunings || []).length;
allLbl.textContent = n ? n + ' selected' : 'All tunings';
}
_updateAllLbl();
titleRow.appendChild(titleEl);
titleRow.appendChild(allLbl);
section.appendChild(titleRow);
var list = document.createElement('div');
list.style.cssText = 'display:flex; flex-direction:column; gap:2px;';
tunings.forEach(function (entry) {
var row = document.createElement('label');
row.style.cssText = 'display:flex; align-items:center; gap:8px; padding:5px 4px; cursor:pointer; border-radius:4px;';
row.addEventListener('mouseenter', function () { row.style.background = '#111827'; });
row.addEventListener('mouseleave', function () { row.style.background = ''; });
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.style.cssText = 'width:14px; height:14px; accent-color:#3b82f6; cursor:pointer; flex-shrink:0;';
cb.checked = (_filters.tunings || []).indexOf(entry.tuning) !== -1;
var lbl = document.createElement('span');
lbl.style.cssText = 'flex:1; font-size:13px; color:#d1d5db;';
lbl.textContent = entry.tuning;
var cnt = document.createElement('span');
cnt.style.cssText = 'font-size:12px; color:#6b7280; font-variant-numeric:tabular-nums;';
cnt.textContent = entry.count;
cb.addEventListener('change', function () {
if (!_filters.tunings) _filters.tunings = [];
if (cb.checked) {
if (_filters.tunings.indexOf(entry.tuning) === -1)
_filters.tunings.push(entry.tuning);
} else {
_filters.tunings = _filters.tunings.filter(function (t) { return t !== entry.tuning; });
}
_saveFilters(); _updateAllLbl(); _updateFilterBadge(); _render();
});
row.appendChild(cb);
row.appendChild(lbl);
row.appendChild(cnt);
list.appendChild(row);
});
section.appendChild(list);
return section;
}
// ── Filter panel open / close (nav) ─────────────────────────────────
function _buildFilterPanel() {
var panel = _el('fb-filter-panel');
if (!panel) return;
panel.innerHTML = '';
var hdr = document.createElement('div');
hdr.style.cssText = 'display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-bottom:1px solid #1f2937; flex-shrink:0;';
var titleEl = document.createElement('span');
titleEl.style.cssText = 'font-size:15px; font-weight:600; color:#e5e7eb;';
titleEl.textContent = 'Filters';
var closeBtn = document.createElement('button');
closeBtn.style.cssText = 'padding:4px; color:#6b7280; background:none; border:none; cursor:pointer; border-radius:4px;';
closeBtn.innerHTML = '';
closeBtn.addEventListener('click', _closeFilterPanel);
hdr.appendChild(titleEl);
hdr.appendChild(closeBtn);
panel.appendChild(hdr);
var content = document.createElement('div');
content.style.cssText = 'overflow-y:auto; flex:1; padding:16px 20px;';
var arrangements = _getArrangements();
var stems = _getStems();
var avail = _getAvailableFilters();
if (arrangements.length) {
var coreArr = _CORE_ARRANGEMENTS.filter(function (a) { return arrangements.indexOf(a) !== -1; });
var otherArr = arrangements.filter(function (a) { return _CORE_ARRANGEMENTS.indexOf(a) === -1; });
content.appendChild(_makePillSection('ARRANGEMENTS',
coreArr.length ? coreArr : arrangements,
'arrangements',
coreArr.length ? otherArr : []
));
}
if (stems.length) content.appendChild(_makePillSection('STEMS (sloppak)', stems, 'stems'));
if (avail.lyrics) content.appendChild(_makeLyricsSection());
if (avail.tuning) content.appendChild(_makeTuningSection());
panel.appendChild(content);
var footer = document.createElement('div');
footer.style.cssText = 'display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-top:1px solid #1f2937; flex-shrink:0;';
var clearBtn = document.createElement('button');
clearBtn.style.cssText = 'font-size:13px; color:#6b7280; background:none; border:none; cursor:pointer; padding:0;';
clearBtn.textContent = 'Clear all';
clearBtn.addEventListener('click', function () {
_filters = { arrangements: {}, stems: {}, lyrics: 'off', tunings: [] };
_saveFilters(); _updateFilterBadge(); _render();
_buildFilterPanel();
});
var doneBtn = document.createElement('button');
doneBtn.style.cssText = 'padding:6px 20px; border-radius:6px; border:none; background:#3b82f6; color:#fff; font-size:13px; cursor:pointer; font-weight:500;';
doneBtn.textContent = 'Done';
doneBtn.addEventListener('click', _closeFilterPanel);
footer.appendChild(clearBtn);
footer.appendChild(doneBtn);
panel.appendChild(footer);
}
function _openFilterPanel() {
_buildFilterPanel();
var panel = _el('fb-filter-panel');
var backdrop = _el('fb-filter-backdrop');
if (panel) panel.style.display = 'flex';
if (backdrop) backdrop.style.display = 'block';
}
function _closeFilterPanel() {
var panel = _el('fb-filter-panel');
var backdrop = _el('fb-filter-backdrop');
if (panel) panel.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}
// ── Sort helper ─────────────────────────────────────────────────────
function _sortSongs(songs) {
if (cfg.ownsSort) {
// Nav: local sort state (#fb-sort + direction toggle).
if (_sort === 'default') return songs;
var arr = songs.slice();
if (_sort === 'title') {
arr.sort(function (a, b) { return (a.title || a.filename).localeCompare(b.title || b.filename); });
} else if (_sort === 'artist') {
arr.sort(function (a, b) { return (a.artist || '').localeCompare(b.artist || ''); });
} else if (_sort === 'duration') {
arr.sort(function (a, b) { return (a.duration || 0) - (b.duration || 0); });
} else if (_sort === 'year') {
arr.sort(function (a, b) { return (a.year || 0) - (b.year || 0); });
} else if (_sort === 'tuning') {
arr.sort(function (a, b) { return (a.tuning || '').localeCompare(b.tuning || ''); });
} else if (_sort === 'added') {
arr.sort(function (a, b) { return (a.added || 0) - (b.added || 0); });
}
if (_sortDir === 'desc') arr.reverse();
return arr;
}
// Lib: read host sort vocabulary (#lib-sort / #v3-songs-sort).
var v = cfg.getHostSort ? cfg.getHostSort() : '';
if (!v) return songs;
var larr = songs.slice();
if (v === 'artist' || v === 'artist-desc') {
larr.sort(function (a, b) { return (a.artist || '').localeCompare(b.artist || ''); });
if (v === 'artist-desc') larr.reverse();
} else if (v === 'title' || v === 'title-desc') {
larr.sort(function (a, b) { return (a.title || a.filename).localeCompare(b.title || b.filename); });
if (v === 'title-desc') larr.reverse();
} else if (v === 'recent') {
larr.sort(function (a, b) { return (b.added || 0) - (a.added || 0); });
} else if (v === 'year-desc') {
larr.sort(function (a, b) { return (b.year || 0) - (a.year || 0); });
} else if (v === 'year') {
larr.sort(function (a, b) { return (a.year || 0) - (b.year || 0); });
} else if (v === 'tuning') {
larr.sort(function (a, b) { return (a.tuning || '').localeCompare(b.tuning || ''); });
}
return larr;
}
// ── Custom modal (Electron blocks prompt/confirm) ───────────────────
// Self-contained: builds its own DOM and keeps element references in
// closure (no global ids), so two surface instances never collide.
var _modalEl = null;
var _modalParts = null;
function _getModal() {
if (_modalEl && document.body.contains(_modalEl)) return _modalParts;
_modalEl = document.createElement('div');
_modalEl.style.cssText = 'display:none; position:fixed; inset:0; z-index:9999; align-items:center; justify-content:center; background:rgba(0,0,0,0.6);';
var box = document.createElement('div');
box.style.cssText = 'background:#1f2937; border:1px solid #374151; border-radius:10px; padding:24px; min-width:320px; max-width:480px; box-shadow:0 8px 40px rgba(0,0,0,0.7);';
var msgEl = document.createElement('div');
msgEl.style.cssText = 'color:#e5e7eb; font-size:14px; white-space:pre-wrap; margin-bottom:16px; line-height:1.5;';
var inp = document.createElement('input');
inp.type = 'text';
inp.style.cssText = 'display:none; width:100%; background:#111827; border:1px solid #4b5563; border-radius:6px; padding:8px 12px; color:#e5e7eb; font-size:14px; outline:none; box-sizing:border-box; margin-bottom:16px;';
var btns = document.createElement('div');
btns.style.cssText = 'display:flex; justify-content:flex-end; gap:8px;';
var cancelBtn = document.createElement('button');
cancelBtn.style.cssText = 'padding:7px 18px; border-radius:6px; border:1px solid #374151; background:transparent; color:#9ca3af; font-size:13px; cursor:pointer;';
cancelBtn.textContent = 'Cancel';
var okBtn = document.createElement('button');
okBtn.style.cssText = 'padding:7px 18px; border-radius:6px; border:none; background:#3b82f6; color:#fff; font-size:13px; font-weight:500; cursor:pointer;';
okBtn.textContent = 'OK';
btns.appendChild(cancelBtn); btns.appendChild(okBtn);
box.appendChild(msgEl); box.appendChild(inp); box.appendChild(btns);
_modalEl.appendChild(box);
document.body.appendChild(_modalEl);
_modalParts = { modal: _modalEl, msgEl: msgEl, input: inp, okBtn: okBtn, cancel: cancelBtn };
return _modalParts;
}
function _showModal(message, withInput, defaultVal) {
return new Promise(function (resolve) {
var p = _getModal();
p.msgEl.textContent = message;
if (withInput) {
p.input.style.display = 'block';
p.input.value = defaultVal || '';
setTimeout(function () { p.input.focus(); p.input.select(); }, 50);
} else {
p.input.style.display = 'none';
}
p.modal.style.display = 'flex';
function _done(val) {
p.modal.style.display = 'none';
p.okBtn.removeEventListener('click', _ok);
p.cancel.removeEventListener('click', _cxl);
p.input.removeEventListener('keydown', _key);
resolve(val);
}
function _ok() { _done(withInput ? p.input.value.trim() : true); }
function _cxl() { _done(null); }
function _key(e) {
if (e.key === 'Enter') { e.preventDefault(); _ok(); }
if (e.key === 'Escape') { e.preventDefault(); _cxl(); }
}
p.okBtn.addEventListener('click', _ok);
p.cancel.addEventListener('click', _cxl);
if (withInput) p.input.addEventListener('keydown', _key);
});
}
function _confirm(msg) { return _showModal(msg, false, ''); }
function _prompt(msg, def) { return _showModal(msg, true, def || ''); }
// ── Song card (grid view) ───────────────────────────────────────────
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;
var artWrap = document.createElement('div');
artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;';
var img = document.createElement('img');
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;';
img.alt = ''; img.loading = 'lazy';
img.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
var ph = document.createElement('div');
ph.style.cssText = 'position:absolute; inset:0; display:flex; align-items:center; justify-content:center;';
ph.innerHTML = '';
img.addEventListener('error', function () { img.style.display = 'none'; ph.style.display = 'flex'; });
img.addEventListener('load', function () { ph.style.display = 'none'; });
artWrap.appendChild(ph); artWrap.appendChild(img);
if (song.duration != null) {
var durB = document.createElement('span');
durB.style.cssText = 'position:absolute; bottom:6px; right:6px; padding:2px 6px; border-radius:4px; font-size:11px; font-weight:600; color:#e5e7eb; background:rgba(0,0,0,0.7);';
var m0 = Math.floor(song.duration / 60), s0 = String(Math.floor(song.duration % 60)).padStart(2, '0');
durB.textContent = m0 + ':' + s0;
artWrap.appendChild(durB);
}
var moveBtn = document.createElement('button');
moveBtn.style.cssText = 'position:absolute; top:6px; right:6px; padding:4px; border-radius:4px; background:rgba(0,0,0,0.6); color:#9ca3af; border:none; cursor:pointer; display:none;';
moveBtn.title = 'Move to folder…';
moveBtn.innerHTML = '';
card.addEventListener('mouseenter', function () { moveBtn.style.display = 'block'; });
card.addEventListener('mouseleave', function () { moveBtn.style.display = 'none'; });
moveBtn.addEventListener('click', function (e) { e.stopPropagation(); _moveSong(song, folderName); });
artWrap.appendChild(moveBtn);
var meta = document.createElement('div');
meta.style.cssText = 'padding:8px 10px 10px; flex:1; min-width:0;';
var title = document.createElement('div');
title.style.cssText = 'font-size:13px; font-weight:600; color:#e5e7eb; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;';
title.textContent = song.title || song.filename;
var sub = document.createElement('div');
sub.style.cssText = 'font-size:11px; color:#6b7280; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:2px;';
sub.textContent = [song.artist, song.album].filter(Boolean).join(' — ') || '';
meta.appendChild(title); meta.appendChild(sub);
if (cfg.songBadges) {
var cardBadges = _buildSongBadges(song);
if (cardBadges) {
meta.appendChild(cardBadges);
card.addEventListener('mouseenter', function () { _revealBadges(cardBadges); });
card.addEventListener('mouseleave', function () { _hideBadges(cardBadges); });
}
var cardDateInfo = _buildSongDateInfo(song);
if (cardDateInfo) {
meta.appendChild(cardDateInfo);
card.addEventListener('mouseenter', function () { _revealBadges(cardDateInfo); });
card.addEventListener('mouseleave', function () { _hideBadges(cardDateInfo); });
}
}
card.appendChild(artWrap); card.appendChild(meta);
card.addEventListener('click', function () {
if (typeof window.playSong === 'function') window.playSong(song.filename);
});
_makeDraggable(card, song, folderName);
return card;
}
// ── Song row (list view) ────────────────────────────────────────────
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;
var thumb = document.createElement('div');
thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;';
var tImg = document.createElement('img');
tImg.loading = 'lazy';
tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
tImg.alt = ''; tImg.style.cssText = 'width:100%; height:100%; object-fit:cover;';
var tPh = document.createElement('div');
tPh.style.cssText = 'position:absolute; inset:0; display:flex; align-items:center; justify-content:center;';
tPh.innerHTML = '';
tImg.addEventListener('error', function () { tImg.style.display = 'none'; tPh.style.display = 'flex'; });
tImg.addEventListener('load', function () { tPh.style.display = 'none'; });
thumb.appendChild(tPh); thumb.appendChild(tImg);
var meta = document.createElement('div');
meta.className = 'flex-1 min-w-0';
var title = document.createElement('div');
title.className = 'text-gray-200 truncate group-hover:text-white';
title.style.cssText = 'font-size:13px; font-weight:600;';
title.textContent = song.title || song.filename;
var sub = document.createElement('div');
sub.className = 'text-gray-500 truncate'; sub.style.fontSize = '11px';
sub.textContent = [song.artist, song.album].filter(Boolean).join(' — ') || '';
meta.appendChild(title); meta.appendChild(sub);
if (cfg.songBadges) {
var rowBadges = _buildSongBadges(song);
if (rowBadges) {
meta.appendChild(rowBadges);
row.addEventListener('mouseenter', function () { _revealBadges(rowBadges); });
row.addEventListener('mouseleave', function () { _hideBadges(rowBadges); });
}
var rowDateInfo = _buildSongDateInfo(song);
if (rowDateInfo) {
meta.appendChild(rowDateInfo);
row.addEventListener('mouseenter', function () { _revealBadges(rowDateInfo); });
row.addEventListener('mouseleave', function () { _hideBadges(rowDateInfo); });
}
}
var icon = document.createElement('span');
icon.className = 'shrink-0 w-4 h-4 text-dark-400 group-hover:text-blue-400 transition-colors opacity-0 group-hover:opacity-100';
icon.innerHTML = '';
var dur = document.createElement('span');
dur.className = 'shrink-0 text-xs text-gray-600 tabular-nums';
if (song.duration != null) {
var m1 = Math.floor(song.duration / 60), s1 = String(Math.floor(song.duration % 60)).padStart(2, '0');
dur.textContent = m1 + ':' + s1;
}
var moveBtn = document.createElement('button');
moveBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400 opacity-0 group-hover:opacity-100 transition-opacity';
moveBtn.title = 'Move to folder…';
moveBtn.innerHTML = '';
moveBtn.addEventListener('click', function (e) { e.stopPropagation(); _moveSong(song, folderName); });
row.appendChild(thumb); row.appendChild(meta); row.appendChild(icon);
row.appendChild(dur); row.appendChild(moveBtn);
row.addEventListener('click', function () {
if (typeof window.playSong === 'function') window.playSong(song.filename);
});
_makeDraggable(row, song, folderName);
return row;
}
// ── Pointer-based drag-and-drop (mousedown/mousemove/mouseup) ───────
// HTML5 DnD blocks wheel events and gives unreliable edge positions in
// Electron — pointer events give full control over both.
var _dragState = null;
var _dragCurrentTarget = null;
var _dragRafId = null;
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
function _getScrollEl() {
var el = _treeEl();
while (el && el !== document.documentElement) {
var ov = window.getComputedStyle(el).overflowY;
if ((ov === 'auto' || ov === 'scroll' || ov === 'overlay') && el.scrollHeight > el.clientHeight) return el;
el = el.parentElement;
}
return document.scrollingElement || document.documentElement;
}
function _dragFindTarget(x, y) {
var els = document.elementsFromPoint(x, y);
for (var i = 0; i < els.length; i++) {
if ('dropFolder' in (els[i].dataset || {})) return els[i];
}
return null;
}
function _dragHighlight(target) {
if (_dragCurrentTarget === target) return;
if (_dragCurrentTarget) _dragCurrentTarget.style.outline = '';
_dragCurrentTarget = target;
if (target) { target.style.outline = '2px solid #3b82f6'; target.style.borderRadius = '6px'; }
}
function _dragScrollTick() {
if (!_dragState || !_dragState.live) { _dragRafId = null; return; }
var h = window.innerHeight, y = _dragState.y;
var sc = _getScrollEl();
sc.style.scrollBehavior = 'auto';
if (y < _DRAG_ZONE) sc.scrollTop -= _DRAG_SPEED;
else if (y > h - _DRAG_ZONE) sc.scrollTop += _DRAG_SPEED;
_dragRafId = requestAnimationFrame(_dragScrollTick);
}
function _onDragMove(e) {
if (!_dragState) return;
_dragState.x = e.clientX; _dragState.y = e.clientY;
if (!_dragState.live) {
var dx = _dragState.x - _dragState.startX, dy = _dragState.y - _dragState.startY;
if (Math.sqrt(dx * dx + dy * dy) < _DRAG_THRESH) return;
_dragState.live = true;
var ghost = document.createElement('div');
ghost.style.cssText = 'position:fixed; pointer-events:none; z-index:9999; padding:5px 12px; background:#1e2130; border:1px solid #3b82f6; border-radius:6px; color:#e5e7eb; font-size:12px; white-space:nowrap; box-shadow:0 4px 20px rgba(0,0,0,0.5);';
ghost.textContent = _dragState.data.label;
document.body.appendChild(ghost);
_dragState.ghost = ghost;
if (!_dragRafId) _dragRafId = requestAnimationFrame(_dragScrollTick);
}
if (_dragState.ghost) {
_dragState.ghost.style.left = (_dragState.x + 14) + 'px';
_dragState.ghost.style.top = (_dragState.y + 14) + 'px';
}
_dragHighlight(_dragFindTarget(_dragState.x, _dragState.y));
}
function _onDragUp(e) {
if (!_dragState) return;
var wasDrag = _dragState.live, data = _dragState.data;
var x = e.clientX, y = e.clientY;
_endDrag();
if (wasDrag) {
// Suppress the click that fires after mouseup so the song doesn't play.
document.addEventListener('click', function (ce) {
ce.stopPropagation(); ce.preventDefault();
}, { capture: true, once: true });
var target = _dragFindTarget(x, y);
if (target && data) {
var tf = target.dataset.dropFolder;
if (tf !== data.folder) _executeDrop(data, tf);
}
}
}
function _onDragKey(e) { if (e.key === 'Escape') _endDrag(); }
function _endDrag() {
if (_dragRafId) { cancelAnimationFrame(_dragRafId); _dragRafId = null; }
if (_dragState && _dragState.ghost) _dragState.ghost.remove();
if (_dragCurrentTarget) { _dragCurrentTarget.style.outline = ''; _dragCurrentTarget = null; }
document.body.style.userSelect = '';
_dragState = null;
document.removeEventListener('mousemove', _onDragMove);
document.removeEventListener('mouseup', _onDragUp);
document.removeEventListener('keydown', _onDragKey);
}
async function _executeDrop(data, targetFolder) {
// No optimistic tree mutation — the drag ghost gives instant visual
// feedback, and racing optimistic updates against _load() caused songs
// to snap back when dropping quickly in succession.
if (targetFolder !== '') _openFolders.add(targetFolder);
else _unsortedOpen = true;
try {
await _api('/song/move', { filename: data.filename, folder: targetFolder });
} catch (err) {
_status('Move failed: ' + err.message, true);
}
await _load(true);
}
function _makeDraggable(el, song, folderName) {
el.style.cursor = 'grab';
el.addEventListener('mousedown', function (e) {
if (e.button !== 0) return;
document.body.style.userSelect = 'none';
var sel = window.getSelection(); if (sel) sel.removeAllRanges();
_dragState = {
data: { filename: song.filename, folder: folderName || '', label: '↕ ' + (song.title || song.filename) },
startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY,
live: false, ghost: null,
};
document.addEventListener('mousemove', _onDragMove);
document.addEventListener('mouseup', _onDragUp);
document.addEventListener('keydown', _onDragKey);
});
el.addEventListener('dragstart', function (e) { e.preventDefault(); });
}
function _makeDropTarget(el, tf) {
el.dataset.dropFolder = (tf == null) ? '' : tf;
}
// ── Move song dialog ────────────────────────────────────────────────
async function _moveSong(song, currentFolderPath) {
if (!_tree) return;
var allPaths = [];
function _collect(f) { allPaths.push(f.path); (f.children || []).forEach(_collect); }
_tree.folders.forEach(_collect);
var options = ['(Unsorted)'].concat(allPaths.filter(function (p) { return p !== currentFolderPath; }));
var choice = await _prompt(
'Move "' + (song.title || song.filename) + '" to:\n' +
options.map(function (n, i) { return i + ': ' + n; }).join('\n') +
'\n\nEnter number or folder path:', ''
);
if (!choice && choice !== 0) return;
var dest = '', idx = parseInt(choice, 10);
if (!isNaN(idx) && idx >= 0 && idx < options.length) {
dest = idx === 0 ? '' : options[idx];
} else {
dest = choice.trim() === '(Unsorted)' ? '' : choice.trim();
}
try {
await _api('/song/move', { filename: song.filename, folder: dest });
await _load(true);
} catch (err) { await _prompt('Move failed: ' + err.message, ''); }
}
// ── Folder section ──────────────────────────────────────────────────
function _folderSection(folder, depth) {
depth = depth || 0;
var q = _query();
var open = q ? true : _openFolders.has(folder.path);
var wrap = document.createElement('div');
function _countDeep(f) {
var n = f.songs.length;
(f.children || []).forEach(function (c) { n += _countDeep(c); });
return n;
}
function _countFoldersDeep(f) {
var n = (f.children || []).length;
(f.children || []).forEach(function (c) { n += _countFoldersDeep(c); });
return n;
}
var hdr = document.createElement('div');
hdr.className = 'flex items-center gap-2 px-3 py-2 rounded cursor-pointer group';
hdr.style.transition = 'background-color 0.1s';
var chev = document.createElement('span');
chev.className = 'shrink-0 w-4 h-4 text-gray-500 transition-transform duration-150';
chev.style.transform = open ? 'rotate(90deg)' : '';
chev.innerHTML = '';
var ico = document.createElement('span');
ico.className = 'shrink-0 w-4 h-4 ' + (depth > 0 ? 'text-yellow-600' : 'text-yellow-500');
ico.innerHTML = '';
var lbl = document.createElement('span');
lbl.className = 'flex-1 truncate font-medium ' + (depth > 0 ? 'text-xs text-gray-400' : 'text-sm text-gray-200');
lbl.textContent = folder.name;
var cnt = document.createElement('span');
if (cfg.deepFolderCount) {
// Lib: deep song + subfolder summary ("N songs · M subfolders").
var _deepTotal = _countDeep(folder);
var _subCount = _countFoldersDeep(folder);
cnt.style.cssText = 'flex-shrink:0; font-size:12px; margin-right:4px; color:#6b7280;';
var _cntText = _deepTotal + ' song' + (_deepTotal === 1 ? '' : 's');
if (_subCount > 0) _cntText += ' · ' + _subCount + ' subfolder' + (_subCount === 1 ? '' : 's');
cnt.textContent = _cntText;
} else {
// Nav: direct song count only.
cnt.className = 'shrink-0 text-xs text-gray-600 tabular-nums mr-1';
cnt.textContent = String(folder.songs.length);
}
var subBtn = document.createElement('button');
subBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400';
subBtn.title = 'New subfolder';
subBtn.innerHTML = '';
subBtn.addEventListener('click', function (e) { e.stopPropagation(); _createFolder(folder.path); });
var renameBtn = document.createElement('button');
renameBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400';
renameBtn.title = 'Rename folder';
renameBtn.innerHTML = '';
renameBtn.addEventListener('click', function (e) { e.stopPropagation(); _renameFolder(folder.path); });
var delBtn = document.createElement('button');
delBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-red-400 hover:bg-dark-400';
delBtn.title = 'Delete folder';
delBtn.innerHTML = '';
delBtn.addEventListener('click', function (e) { e.stopPropagation(); _deleteFolder(folder.path, _countDeep(folder), _countFoldersDeep(folder)); });
var expandChildBtn = document.createElement('button');
var collapseChildBtn = document.createElement('button');
if (folder.children && folder.children.length) {
expandChildBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400';
expandChildBtn.title = 'Expand all subfolders';
expandChildBtn.innerHTML = '';
expandChildBtn.addEventListener('click', function (e) {
e.stopPropagation();
_openFolders.add(folder.path);
(folder.children || []).forEach(function (c) { _openFolders.add(c.path); });
_storeJSON('open', [..._openFolders]); _render();
});
collapseChildBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400';
collapseChildBtn.title = 'Collapse all subfolders';
collapseChildBtn.innerHTML = '';
collapseChildBtn.addEventListener('click', function (e) {
e.stopPropagation();
(folder.children || []).forEach(function (c) { _openFolders.delete(c.path); });
_storeJSON('open', [..._openFolders]); _render();
});
}
// Collapsing button group — 0 width when hidden, slides in on hover.
var btnGroup = document.createElement('div');
btnGroup.style.cssText = 'display:flex; align-items:center; gap:2px; max-width:0; overflow:hidden; transition:max-width 0.2s ease;';
if (folder.children && folder.children.length) {
btnGroup.appendChild(expandChildBtn); btnGroup.appendChild(collapseChildBtn);
}
btnGroup.appendChild(subBtn); btnGroup.appendChild(renameBtn); btnGroup.appendChild(delBtn);
// mouseover (bubbles) + stopPropagation so only the innermost folder activates.
wrap.style.cssText = 'border-radius:6px; margin:1px 0;';
wrap.addEventListener('mouseover', function (e) {
if (_dragState) return;
e.stopPropagation();
if (_hoveredFolder && _hoveredFolder.wrap !== wrap) {
_hoveredFolder.hdr.style.backgroundColor = '';
_hoveredFolder.wrap.style.backgroundColor = '';
_hoveredFolder.btnGroup.style.maxWidth = '0';
}
_hoveredFolder = { wrap: wrap, hdr: hdr, btnGroup: btnGroup };
hdr.style.backgroundColor = 'rgba(55,65,81,0.5)';
wrap.style.backgroundColor = 'rgba(55,65,81,0.12)';
btnGroup.style.maxWidth = '160px';
});
wrap.addEventListener('mouseout', function (e) {
if (_dragState) return;
if (wrap.contains(e.relatedTarget)) return;
hdr.style.backgroundColor = ''; wrap.style.backgroundColor = '';
btnGroup.style.maxWidth = '0';
if (_hoveredFolder && _hoveredFolder.wrap === wrap) _hoveredFolder = null;
});
// cnt sits after btnGroup so it rests at the far right when buttons hidden.
hdr.appendChild(chev); hdr.appendChild(ico); hdr.appendChild(lbl);
hdr.appendChild(btnGroup); hdr.appendChild(cnt);
_makeDropTarget(hdr, folder.path);
var content = document.createElement('div');
if (!open) content.style.display = 'none';
var list = document.createElement('div');
if (_view === 'grid') {
list.style.cssText = 'display:grid; grid-template-columns:repeat(auto-fill,150px); justify-content:start; gap:12px; padding:8px 4px 8px 24px;';
} else {
list.className = 'ml-5 mt-0.5 space-y-0';
}
_makeDropTarget(list, folder.path);
var childrenWrap = document.createElement('div');
// Suppress grid padding on empty song lists — prevents a blank amber stub.
if (_view === 'grid' && !folder.songs.length) list.style.padding = '0';
var _listPopulated = open;
function _populateList() {
_sortSongs(folder.songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
});
(folder.children || []).forEach(function (child) {
childrenWrap.appendChild(_folderSection(child, depth + 1));
});
}
if (open) _populateList();
// depth > 0: one container with a continuous amber border-left grouping
// songs + child folders. depth == 0: children indent, root songs unbordered.
var innerWrap = null;
if (depth > 0) {
innerWrap = document.createElement('div');
innerWrap.style.cssText = 'margin-left:32px; padding-left:10px; border-left:2px solid rgba(234,179,8,0.35);';
innerWrap.appendChild(list); innerWrap.appendChild(childrenWrap);
content.appendChild(innerWrap);
} else {
childrenWrap.style.marginLeft = '32px';
content.appendChild(list); content.appendChild(childrenWrap);
}
content.addEventListener('click', function (e) {
if (_query()) return;
var bgEls = [content, list, childrenWrap];
if (innerWrap) bgEls.push(innerWrap);
if (bgEls.indexOf(e.target) === -1) return;
if (content.style.display !== 'none') {
content.style.display = 'none'; chev.style.transform = '';
_openFolders.delete(folder.path); _storeJSON('open', [..._openFolders]);
}
});
hdr.addEventListener('click', function () {
if (_query()) return;
var nowOpen = content.style.display === 'none';
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
content.style.display = nowOpen ? '' : 'none';
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
if (nowOpen) _openFolders.add(folder.path);
else _openFolders.delete(folder.path);
_storeJSON('open', [..._openFolders]);
});
wrap.appendChild(hdr); wrap.appendChild(content);
return wrap;
}
// ── Unsorted section ────────────────────────────────────────────────
function _unsortedSection(songs) {
var q = _query();
if (!songs.length && q) return null;
var wrap = document.createElement('div');
wrap.className = 'mb-1';
var hdr = document.createElement('div');
hdr.className = 'flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 transition-colors duration-100';
var chev = document.createElement('span');
chev.className = 'shrink-0 w-4 h-4 text-gray-600 transition-transform duration-150';
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
chev.innerHTML = '';
var ico = document.createElement('span');
ico.className = 'shrink-0 w-4 h-4 text-gray-600';
ico.innerHTML = '';
var lbl = document.createElement('span');
lbl.className = 'flex-1 text-xs font-semibold uppercase tracking-widest text-gray-600';
lbl.textContent = 'Unsorted';
var cnt = document.createElement('span');
cnt.className = 'shrink-0 text-xs text-gray-700 tabular-nums';
cnt.textContent = String(songs.length);
hdr.appendChild(chev); hdr.appendChild(ico); hdr.appendChild(lbl); hdr.appendChild(cnt);
_makeDropTarget(hdr, '');
var list = document.createElement('div');
if (_view === 'grid') {
list.style.cssText = 'display:grid; grid-template-columns:repeat(auto-fill,150px); justify-content:start; gap:12px; padding:8px 4px 8px 24px;';
} else {
list.className = 'ml-5 mt-0.5 space-y-0';
}
var _populated = _unsortedOpen;
function _populate() {
_sortSongs(songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
});
}
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
_makeDropTarget(list, '');
hdr.addEventListener('click', function () {
if (_query()) return;
_unsortedOpen = list.style.display === 'none';
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
_store(cfg.unsortedKey, String(_unsortedOpen));
});
wrap.appendChild(hdr); wrap.appendChild(list);
return wrap;
}
// ── Folder management ───────────────────────────────────────────────
async function _createFolder(parentPath) {
var msg = parentPath ? 'New subfolder name in "' + parentPath.split('/').pop() + '":' : 'New folder name:';
var name = await _prompt(msg);
if (!name || !name.trim()) return;
try {
var body = { name: name.trim() };
if (parentPath) body.parent = parentPath;
await _api('/folder/create', body);
var newPath = parentPath ? parentPath + '/' + name.trim() : name.trim();
if (parentPath) _openFolders.add(parentPath);
_openFolders.add(newPath);
await _load(true);
} catch (err) { await _prompt('Create failed: ' + err.message); }
}
async function _renameFolder(folderPath) {
var oldName = folderPath.split('/').pop();
var newName = await _prompt('Rename "' + oldName + '" to:', oldName);
if (!newName || !newName.trim() || newName.trim() === oldName) return;
try {
await _api('/folder/rename', { old: folderPath, new: newName.trim() });
var parts = folderPath.split('/');
parts[parts.length - 1] = newName.trim();
var newPath = parts.join('/');
var updated = new Set();
_openFolders.forEach(function (p) {
if (p === folderPath) updated.add(newPath);
else if (p.startsWith(folderPath + '/')) updated.add(newPath + p.slice(folderPath.length));
else updated.add(p);
});
_openFolders = updated;
_storeJSON('open', [..._openFolders]);
await _load(true);
} catch (err) { await _prompt('Rename failed: ' + err.message); }
}
async function _deleteFolder(folderPath, songCount, folderCount) {
var folderName = folderPath.split('/').pop();
var parts = [];
if (songCount > 0) parts.push(songCount + ' song' + (songCount === 1 ? '' : 's'));
if (folderCount > 0) parts.push(folderCount + ' subfolder' + (folderCount === 1 ? '' : 's'));
var msg = parts.length
? 'Delete "' + folderName + '"? It contains ' + parts.join(' and ') + '. Songs will be moved to Unsorted.'
: 'Delete empty folder "' + folderName + '"?';
var ok = await _confirm(msg);
if (!ok) return;
try {
await _api('/folder/delete', { name: folderPath });
var toDelete = [];
_openFolders.forEach(function (p) {
if (p === folderPath || p.startsWith(folderPath + '/')) toDelete.push(p);
});
toDelete.forEach(function (p) { _openFolders.delete(p); });
_storeJSON('open', [..._openFolders]);
await _load(true);
} catch (err) { await _prompt('Delete failed: ' + err.message); }
}
// ── Expand / collapse all ───────────────────────────────────────────
function _expandAll() {
if (!_tree) return;
function _addPaths(f) { _openFolders.add(f.path); (f.children || []).forEach(_addPaths); }
_tree.folders.forEach(_addPaths);
_unsortedOpen = true;
_storeJSON('open', [..._openFolders]); _store(cfg.unsortedKey, 'true');
_render();
}
function _collapseAll() {
_openFolders.clear(); _unsortedOpen = false;
_storeJSON('open', []); _store(cfg.unsortedKey, 'false');
_render();
}
// ── Render ──────────────────────────────────────────────────────────
function _render() {
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
var treeEl = _treeEl();
if (!treeEl) return;
var data = _filtered();
var frag = document.createDocumentFragment();
var unsorted = _unsortedSection(data.root_songs);
if (unsorted) frag.appendChild(unsorted);
data.folders.forEach(function (f) { frag.appendChild(_folderSection(f)); });
if (!data.folders.length && !data.root_songs.length) {
var emp = document.createElement('div');
emp.className = 'flex flex-col items-center justify-center py-24 gap-3 text-gray-700';
emp.innerHTML = '' +
'
' + (_query() ? 'No songs match your search.' : 'No songs found.') + '
';
frag.appendChild(emp);
}
treeEl.innerHTML = ''; treeEl.appendChild(frag);
// Lib: update the library count line ("N songs · M folders").
if (cfg.countId) {
var countEl = _el(cfg.countId);
if (countEl) {
var total = data.root_songs.length;
var folderCount = 0;
function _countDeep(f) {
total += f.songs.length;
folderCount += 1;
(f.children || []).forEach(_countDeep);
}
data.folders.forEach(_countDeep);
var songStr = total + ' song' + (total === 1 ? '' : 's');
var folderStr = folderCount + ' folder' + (folderCount === 1 ? '' : 's');
countEl.textContent = songStr + ' · ' + folderStr;
}
}
}
// ── Toolbar injection (lib surface, once) ───────────────────────────
function _injectToolbar() {
if (_toolbarDone) return;
var ctrl = _el(cfg.controlsId);
if (!ctrl) {
ctrl = document.createElement('div');
ctrl.id = cfg.controlsId;
var treeEl = _treeEl();
if (!treeEl) return;
treeEl.parentNode.insertBefore(ctrl, treeEl);
}
ctrl.style.cssText = 'display:flex; align-items:center; gap:8px; margin-bottom:12px;';
ctrl.innerHTML = '';
var viewGroup = document.createElement('div');
viewGroup.style.cssText = 'display:flex; background:#1f2937; border:1px solid #374151; border-radius:10px; overflow:hidden;';
var listBtn = document.createElement('button');
listBtn.title = 'List view';
listBtn.style.cssText = 'padding:7px 10px; border:none; cursor:pointer; transition:background 0.1s, color 0.1s;';
listBtn.innerHTML = '';
var gridBtn = document.createElement('button');
gridBtn.title = 'Grid view';
gridBtn.style.cssText = 'padding:7px 10px; border:none; cursor:pointer; transition:background 0.1s, color 0.1s;';
gridBtn.innerHTML = '';
function _applyViewBtns() {
listBtn.style.background = _view === 'list' ? '#374151' : 'transparent';
listBtn.style.color = _view === 'list' ? '#e5e7eb' : '#6b7280';
gridBtn.style.background = _view === 'grid' ? '#374151' : 'transparent';
gridBtn.style.color = _view === 'grid' ? '#e5e7eb' : '#6b7280';
}
_applyViewBtns();
listBtn.addEventListener('click', function () {
if (_view === 'list') return;
_view = 'list'; _store('view', 'list'); _applyViewBtns(); _render();
});
gridBtn.addEventListener('click', function () {
if (_view === 'grid') return;
_view = 'grid'; _store('view', 'grid'); _applyViewBtns(); _render();
});
viewGroup.appendChild(listBtn); viewGroup.appendChild(gridBtn);
var newBtn = _makeToolbarBtn(
'',
null, 'New parent folder'
);
newBtn.addEventListener('click', function () { _createFolder(); });
var expBtn = _makeToolbarBtn(
'',
null, 'Expand all'
);
expBtn.addEventListener('click', _expandAll);
var colBtn = _makeToolbarBtn(
'',
null, 'Collapse all'
);
colBtn.addEventListener('click', _collapseAll);
ctrl.appendChild(viewGroup);
ctrl.appendChild(newBtn);
ctrl.appendChild(expBtn);
ctrl.appendChild(colBtn);
_toolbarDone = true;
}
function _makeToolbarBtn(iconHtml, label, title) {
var btn = document.createElement('button');
btn.title = title || '';
btn.style.cssText = 'display:flex; align-items:center; gap:6px; padding:7px 12px; background:#1f2937; border:1px solid #374151; border-radius:10px; color:#9ca3af; cursor:pointer; font-size:13px; white-space:nowrap; transition:color 0.1s, border-color 0.1s;';
btn.innerHTML = iconHtml + (label ? '' + label + '' : '');
btn.addEventListener('mouseenter', function () { btn.style.color = '#e5e7eb'; btn.style.borderColor = '#6b7280'; });
btn.addEventListener('mouseleave', function () { btn.style.color = '#9ca3af'; btn.style.borderColor = '#374151'; });
return btn;
}
// ── Unload (lib surface) ────────────────────────────────────────────
function _unload() {
if (!cfg.searchInputId) return;
var el = _el(cfg.searchInputId);
if (el) el.style.maxWidth = '';
}
// ── Init (nav surface) ──────────────────────────────────────────────
function _init() {
_closeDropdown();
_fixHeight();
window.addEventListener('resize', _fixHeight);
var search = _el('fb-search');
var reload = _el('fb-reload');
var expandAll = _el('fb-expand-all');
var collapseAll = _el('fb-collapse-all');
var newFolder = _el('fb-new-folder');
var filterBtn = _el('fb-filter');
var filterBack = _el('fb-filter-backdrop');
var viewList = _el('fb-view-list');
var viewGrid = _el('fb-view-grid');
if (!search) return;
search.style.position = 'relative';
search.style.zIndex = '100';
function _updateViewButtons() {
if (!viewList || !viewGrid) return;
viewList.style.color = _view === 'list' ? '#ffffff' : '';
viewList.style.background = _view === 'list' ? '#1f2937' : '';
viewGrid.style.color = _view === 'grid' ? '#ffffff' : '';
viewGrid.style.background = _view === 'grid' ? '#1f2937' : '';
}
_updateViewButtons();
if (viewList) viewList.addEventListener('click', function () {
if (_view === 'list') return;
_view = 'list'; _store('view', 'list'); _updateViewButtons(); _render();
});
if (viewGrid) viewGrid.addEventListener('click', function () {
if (_view === 'grid') return;
_view = 'grid'; _store('view', 'grid'); _updateViewButtons(); _render();
});
var sortSel = _el('fb-sort');
var sortDirBtn = _el('fb-sort-dir');
var sortDirIco = _el('fb-sort-dir-icon');
function _updateSortDir() {
if (!sortDirBtn) return;
var isAsc = _sortDir === 'asc';
var active = _sort !== 'default';
sortDirBtn.title = isAsc ? 'Ascending' : 'Descending';
sortDirBtn.style.opacity = active ? '' : '0.35';
sortDirBtn.style.cursor = active ? '' : 'default';
if (sortDirIco) {
sortDirIco.innerHTML = isAsc
? ''
: '';
}
}
_updateSortDir();
if (sortSel) {
sortSel.value = _sort;
sortSel.addEventListener('change', function () {
_sort = sortSel.value; _store('sort', _sort); _updateSortDir(); _render();
});
}
if (sortDirBtn) {
sortDirBtn.addEventListener('click', function () {
if (_sort === 'default') return;
_sortDir = _sortDir === 'asc' ? 'desc' : 'asc';
_store('sortDir', _sortDir); _updateSortDir(); _render();
});
}
search.addEventListener('input', function () { _render(); });
search.addEventListener('click', function (e) { e.stopPropagation(); _closeDropdown(); });
reload.addEventListener('click', function () { _loaded = false; _load(true); });
expandAll.addEventListener('click', _expandAll);
collapseAll.addEventListener('click', _collapseAll);
newFolder.addEventListener('click', function () { _createFolder(); });
if (filterBtn) filterBtn.addEventListener('click', _openFilterPanel);
if (filterBack) filterBack.addEventListener('click', _closeFilterPanel);
_updateFilterBadge();
if (!_loaded) _load(true);
}
// ── Screen changed (nav surface) ────────────────────────────────────
function _onScreenChanged(ev) {
var id = ev && ev.detail && ev.detail.id;
if (id === cfg.screenId) {
_closeDropdown();
if (!_loaded) _load(true);
}
}
return {
load: _load,
unload: _unload,
init: _init,
onScreenChanged: _onScreenChanged,
render: _render,
};
}
// ════════════════════════════════════════════════════════════════════════
// Surface configs
// ════════════════════════════════════════════════════════════════════════
var NAV_CONFIG = {
apiBase: API,
storePrefix: 'fo:',
treeId: 'fb-tree',
screenId: 'plugin-folder_library',
unsortedKey: 'unsorted_open',
ownsStatus: true,
ownsFilterPanel:true,
ownsSort: true,
songBadges: true,
deepFolderCount:false,
autoExpandTop: false,
injectToolbar: false,
getSearchEl: function () { return document.getElementById('fb-search'); },
};
var LIB_CONFIG = {
apiBase: API,
storePrefix: 'fo:lib:',
treeId: 'lib-folder-tree',
controlsId: 'lib-folder-controls',
countId: 'lib-count',
searchInputId: 'lib-filter',
unsortedKey: 'unsorted',
ownsStatus: false,
ownsFilterPanel:false,
ownsSort: false,
songBadges: false,
deepFolderCount:true,
autoExpandTop: true,
injectToolbar: true,
getSearchEl: function () { return document.getElementById('v3-search') || document.getElementById('lib-filter'); },
getFilterParams: function () {
return (typeof window.v3Songs?.filterParams === 'function')
? window.v3Songs.filterParams()
: (typeof window.feedBackLibFilterParams === 'function')
? window.feedBackLibFilterParams()
: (typeof window.slopsmithLibFilterParams === 'function')
? window.slopsmithLibFilterParams() : '';
},
getHostArtist: function () { return (typeof window.v3Songs?.getArtist === 'function') ? window.v3Songs.getArtist() : ''; },
getHostAlbum: function () { return (typeof window.v3Songs?.getAlbum === 'function') ? window.v3Songs.getAlbum() : ''; },
getHostSort: function () {
return (typeof window.v3Songs?.getSort === 'function')
? window.v3Songs.getSort()
: (document.getElementById('lib-sort') || document.getElementById('v3-songs-sort') || {}).value || '';
},
};
// ════════════════════════════════════════════════════════════════════════
// Adapter A — v2 nav screen (renders into #fb-tree)
// ════════════════════════════════════════════════════════════════════════
if (!window.__folderLibraryNavLoaded) {
window.__folderLibraryNavLoaded = true;
var _nav = createFolderSurface(NAV_CONFIG);
if (window.feedBack && typeof window.feedBack.on === 'function') {
window.feedBack.on('screen:changed', _nav.onScreenChanged);
} else if (window.slopsmith && typeof window.slopsmith.on === 'function') {
window.slopsmith.on('screen:changed', _nav.onScreenChanged);
} else {
var _deadline = performance.now() + 5000;
var _pollId = setInterval(function () {
var bus = window.feedBack || window.slopsmith;
if (bus && typeof bus.on === 'function') {
clearInterval(_pollId);
bus.on('screen:changed', _nav.onScreenChanged);
} else if (performance.now() > _deadline) {
clearInterval(_pollId);
}
}, 100);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _nav.init, { once: true });
} else {
_nav.init();
}
}
// ════════════════════════════════════════════════════════════════════════
// Adapter B — v3 library view (window.folderLibrary, renders into #lib-folder-tree)
// ════════════════════════════════════════════════════════════════════════
// Idempotency: the factory instance is a persistent singleton (so its in-memory
// state survives a script re-injection), but window.folderLibrary is ALWAYS
// (re)assigned on every evaluation — the host's reload path does
// `delete window.folderLibrary` and re-injects this script expecting it back.
if (!window.__folderLibraryLib) {
window.__folderLibraryLib = createFolderSurface(LIB_CONFIG);
}
(function () {
var _lib = window.__folderLibraryLib;
window.folderLibrary = {
load: function (force) { return _lib.load(force); },
unload: function () { _lib.unload(); },
};
// Auto-load if folder view was already active when this script was injected.
// On a hard refresh, setLibView() runs before plugins load, so
// window.folderLibrary didn't exist yet and the host's load call silently
// skipped. Now that we're defined, kick off the load if #lib-folder-tree is
// currently visible.
var treeEl = document.getElementById('lib-folder-tree');
if (treeEl && !treeEl.classList.contains('hidden')) {
_lib.load();
}
}());
})();