/* 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(); } }()); })();