mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
Adds the bundled Folder Library plugin (browse the DLC library by its on-disk folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter, live search), wired into the classic v2 toolbar and the v3 Songs page. Includes the screen.js IIFE dedup (unified surface factory) and review fixes: path-traversal guard on /song/move, folder-delete data-loss fix, plural /api/plugins/<id> namespace, loose-folder song recognition, error-text escaping, v3 setLibView null-guard, and tests. Co-authored-by: Kyle <kyle.j.t@live.co.uk> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1673 lines
88 KiB
JavaScript
1673 lines
88 KiB
JavaScript
/* 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 = '<div style="padding:48px;text-align:center;color:#4b5563;font-size:13px;">Loading folders…</div>';
|
|
}
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:16px;height:16px"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
|
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 = '<svg viewBox="0 0 48 48" fill="none" stroke="#374151" stroke-width="1.5" style="width:40px;height:40px"><path d="M6 12a4 4 0 014-4h4l4 4h16a4 4 0 014 4v16a4 4 0 01-4 4H10a4 4 0 01-4-4V12z"/><circle cx="20" cy="26" r="3"/><path d="M23 26v-8l8-2v8"/><circle cx="31" cy="24" r="3"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:12px;height:12px"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/><path fill-rule="evenodd" d="M10 11a1 1 0 011 1v2h2a1 1 0 110 2h-2v2a1 1 0 11-2 0v-2H7a1 1 0 110-2h2v-2a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="none" stroke="#374151" stroke-width="1.5" style="width:14px;height:14px"><path d="M9 19H5a2 2 0 01-2-2V7a2 2 0 012-2h2l2 2h6a2 2 0 012 2v2"/><circle cx="13" cy="16" r="2"/><path d="M15 16v-4l3-1v4"/><circle cx="18" cy="15" r="2"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" class="w-full h-full"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>';
|
|
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/><path fill-rule="evenodd" d="M10 11a1 1 0 011 1v2h2a1 1 0 110 2h-2v2a1 1 0 11-2 0v-2H7a1 1 0 110-2h2v-2a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" class="w-full h-full"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>';
|
|
|
|
var ico = document.createElement('span');
|
|
ico.className = 'shrink-0 w-4 h-4 ' + (depth > 0 ? 'text-yellow-600' : 'text-yellow-500');
|
|
ico.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" class="w-full h-full"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>';
|
|
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/><path fill-rule="evenodd" d="M10 11a1 1 0 011 1v2h2a1 1 0 110 2h-2v2a1 1 0 11-2 0v-2H7a1 1 0 110-2h2v-2a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="width:14px;height:14px"><path d="M5 8l5 5 5-5"/><path d="M5 4l5 5 5-5" opacity=".4"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="width:14px;height:14px"><path d="M5 12l5-5 5 5"/><path d="M5 16l5-5 5 5" opacity=".4"/></svg>';
|
|
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 = '<svg viewBox="0 0 20 20" fill="currentColor" class="w-full h-full"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>';
|
|
|
|
var ico = document.createElement('span');
|
|
ico.className = 'shrink-0 w-4 h-4 text-gray-600';
|
|
ico.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" class="w-full h-full"><path d="M2 6a2 2 0 012-2h12a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>';
|
|
|
|
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 = '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="w-12 h-12"><path d="M6 12a4 4 0 014-4h8l4 4h16a4 4 0 014 4v20a4 4 0 01-4 4H10a4 4 0 01-4-4V12z"/></svg>' +
|
|
'<p class="text-sm">' + (_query() ? 'No songs match your search.' : 'No songs found.') + '</p>';
|
|
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 = '<svg viewBox="0 0 16 16" fill="currentColor" style="width:14px;height:14px;display:block;"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>';
|
|
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 = '<svg viewBox="0 0 16 16" fill="currentColor" style="width:14px;height:14px;display:block;"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>';
|
|
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(
|
|
'<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px;flex-shrink:0"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/><path fill-rule="evenodd" d="M10 11a1 1 0 011 1v2h2a1 1 0 110 2h-2v2a1 1 0 11-2 0v-2H7a1 1 0 110-2h2v-2a1 1 0 011-1z" clip-rule="evenodd"/></svg>',
|
|
null, 'New parent folder'
|
|
);
|
|
newBtn.addEventListener('click', function () { _createFolder(); });
|
|
var expBtn = _makeToolbarBtn(
|
|
'<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="width:14px;height:14px"><path d="M5 8l5 5 5-5"/><path d="M5 4l5 5 5-5" opacity=".4"/></svg>',
|
|
null, 'Expand all'
|
|
);
|
|
expBtn.addEventListener('click', _expandAll);
|
|
var colBtn = _makeToolbarBtn(
|
|
'<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="width:14px;height:14px"><path d="M5 12l5-5 5 5"/><path d="M5 16l5-5 5 5" opacity=".4"/></svg>',
|
|
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 ? '<span>' + label + '</span>' : '');
|
|
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
|
|
? '<path d="M5 12l5-5 5 5"/>'
|
|
: '<path d="M5 8l5 5 5-5"/>';
|
|
}
|
|
}
|
|
_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();
|
|
}
|
|
}());
|
|
|
|
})();
|